Files
OSGKeyboard/OSGKeyboard/Services/FlowSessionManager.swift
T
rocky 6e64f96dce fix(flow): spread PiP recovery attempts across the budget after a media server reset
- Schedule up to four attempts at fixed offsets (0, 0.5, 1.5, 3 s) from the start of recovery instead of short gaps after each fast failure.
- Wake the wait early when AVAudioSession reports mediaServicesWereReset.
- Re-apply the playback category and reactivate the session instead of trusting cached state from before the reset.
2026-10-20 23:17:46 +02:00

3999 lines
162 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FlowSessionManager.swift
// OSGKeyboard · Main App
//
// Session Owner for TypeWhisper-style Flow dictation: continuous
// `.playAndRecord` capture for the whole session, utterance gating for
// ASR and cloud LLM polish, with App Group result delivery.
import AVFoundation
import Foundation
import OSGKeyboardHostSupport
import OSGKeyboardShared
import Speech
import SwiftUI
import UIKit
struct FlowUtteranceStartToken: Equatable, Sendable {
let generation: UInt64
let utteranceId: UUID
}
enum FlowUtteranceLifecyclePolicy {
static func canContinueStart(
token: FlowUtteranceStartToken,
currentGeneration: UInt64,
currentUtteranceId: UUID?,
terminalUtteranceIds: Set<UUID>
) -> Bool {
currentGeneration == token.generation
&& currentUtteranceId == token.utteranceId
&& !terminalUtteranceIds.contains(token.utteranceId)
}
}
enum FlowPiPActivationTrigger: String, Sendable {
case coldStart
case foreground
case existingSession
case startupRecovery
case unexpectedStop
case healthCheck
case manualRetry
}
enum FlowPiPReconciliationDecision: Equatable, Sendable {
case noSessionIntent
case alreadyActive
case waitForForeground
case startRecovery
}
/// Guarantees that every user-visible session warning (the home-screen
/// "cannot start voice session" toast) leaves a report behind.
///
/// Most warning sites already write their own, better-labelled report on the
/// following lines. This gate lets the catch-all skip those and fire only for
/// the paths nobody instrumented — which is exactly where a silent toast comes
/// from.
struct FlowSessionWarningReportGate: Equatable, Sendable {
/// An explicit report this recent is assumed to describe the same incident.
static let duplicateGrace: TimeInterval = 1.5
private var lastReportedWarning: String?
mutating func shouldReport(
warning: String,
lastExplicitReportAt: Date?,
now: Date
) -> Bool {
guard lastReportedWarning != warning else { return false }
lastReportedWarning = warning
guard let lastExplicitReportAt else { return true }
return now.timeIntervalSince(lastExplicitReportAt) >= Self.duplicateGrace
}
/// A cleared warning ends the episode: the same text later is a new toast.
mutating func warningCleared() {
lastReportedWarning = nil
}
}
/// One startup-failure report per PiP-loss episode.
///
/// While the app is backgrounded the heartbeat re-evaluates the lost PiP every
/// second; without this gate a single incident would write dozens of identical
/// reports and evict everything else from the 40-report budget.
struct FlowPiPLossReportGate: Equatable, Sendable {
private var reportedForCurrentEpisode = false
mutating func shouldReport() -> Bool {
guard !reportedForCurrentEpisode else { return false }
reportedForCurrentEpisode = true
return true
}
/// A healthy PiP closes the episode, so the next loss reports again.
mutating func pipBecameActive() {
reportedForCurrentEpisode = false
}
}
enum FlowPiPRecoveryPolicy {
static let maxAttempts = 4
static let totalBudget: TimeInterval = 5
static func reconciliationDecision(
wantsActiveSession: Bool,
isAppForeground: Bool,
isPictureInPictureActive: Bool
) -> FlowPiPReconciliationDecision {
guard wantsActiveSession else { return .noSessionIntent }
if isPictureInPictureActive { return .alreadyActive }
return isAppForeground ? .startRecovery : .waitForForeground
}
static func canContinue(
operation: UInt64,
currentOperation: UInt64,
wantsActiveSession: Bool,
isAppForeground: Bool,
taskIsCancelled: Bool
) -> Bool {
operation == currentOperation
&& wantsActiveSession
&& isAppForeground
&& !taskIsCancelled
}
/// Earliest start of each attempt, measured from the start of recovery.
/// Audio activation failures return almost instantly, so gaps measured
/// after each failure would spend every attempt within ~1 s, while a reset
/// media server needs ~3 s before activation succeeds again.
static func attemptOffset(beforeAttempt attempt: Int) -> TimeInterval {
switch attempt {
case 1: return 0
case 2: return 0.5
case 3: return 1.5
default: return 3
}
}
static func hostTimeout(attempt: Int, remainingBudget: TimeInterval) -> TimeInterval {
min(attempt == 1 ? 1.5 : 0.25, max(0, remainingBudget))
}
static func activeTimeout(remainingBudget: TimeInterval) -> TimeInterval {
min(1.4, max(0, remainingBudget))
}
}
@MainActor
final class FlowSessionManager: ObservableObject {
private struct PrefilledAIRequest {
let question: String
let conversationID: UUID?
let webPageURL: URL?
let taskKind: ManagedGatewayTaskKind
let requestSource: ManagedGatewayRequestSource?
let requestPurpose: ManagedGatewayRequestPurpose?
let oobeFeature: ManagedGatewayOOBEFeature?
let thinkingEnabled: Bool
}
@Published private(set) var isActive = false
@Published private(set) var isStarting = false
@Published private(set) var sessionExpiresAt: Date?
/// Non-nil when continuous capture failed or permissions are missing.
@Published private(set) var sessionWarning: String? {
didSet {
guard let sessionWarning else {
sessionWarningReportGate.warningCleared()
return
}
guard sessionWarning != oldValue else { return }
// Deferred by one runloop turn on purpose: the paths that raise a
// warning keep mutating lifecycle state — and usually write their
// own, better-labelled report — on the following lines. Reporting
// synchronously here would snapshot half-updated state and
// double-count the same incident.
Task { @MainActor [weak self] in
self?.reportSessionWarningIfUnreported(sessionWarning)
}
}
}
private var sessionWarningReportGate = FlowSessionWarningReportGate()
@Published private(set) var pipLifecycleState: FlowPiPLifecycleState = .inactive {
didSet {
// A healthy PiP closes the current loss episode, so the next loss
// reports again instead of being deduped against a stale one.
if pipLifecycleState == .active { pipLossReportGate.pipBecameActive() }
}
}
private var pipLossReportGate = FlowPiPLossReportGate()
private let capture = FlowContinuousCapture()
private let pipController: any FlowPictureInPictureControlling
private let store = AppGroupStore()
private let analyticsClient: any AnalyticsClient
/// Cloud / local polish via the user-configured LLM provider.
private let polisher: PolishingService
/// AI-mode turns are intentionally process-local and never persisted.
private let aiConversations = AIConversationStore()
private let webPageContentService: WebPageContentService
/// Cached ASR instance. The only on-device backend is iOS
/// `SpeechAnalyzer`, which has no warm-up step — we can hand the
/// factory-built service straight back without going through the
/// old `OnDeviceModelWarmup` registry.
private var sessionASR: ASRService?
/// Tracks which engine mode `sessionASR` was created for.
private var sessionASREngineMode: String?
/// Locale id last passed to `warmup(locale:)`.
private var sessionASRWarmedLocaleID: String?
private var asr: ASRService {
if let sessionASR { return sessionASR }
let service = ASRServiceFactory.make(store: store)
sessionASR = service
return service
}
private var pollingTask: Task<Void, Never>?
private var heartbeatTask: Task<Void, Never>?
private var levelTask: Task<Void, Never>?
private var startTask: Task<Void, Never>?
private var audioPrimeTask: Task<Bool, Never>?
private var audioPrimeID: UUID?
private var audioPrimeCancellationRequested = false
private var startupAudioHealthTask: Task<Void, Never>?
private var didRunStartupAudioHealthCheck = false
private var commandObserver: FlowSessionDarwinObserver?
/// Alive for the whole process, not just an active session: the keyboard
/// asks for a companion dump exactly when no session exists.
private var diagnosticsDumpObserver: FlowSessionDarwinObserver?
/// Last recording state the poll loop observed — logs only on transition.
private var lastObservedRecordingState: FlowSessionKeys.RecordingState = .idle
private var activeSessionId: UUID?
private var currentUtteranceId: UUID?
/// Claimed synchronously before any async capture work begins.
private var startingUtteranceId: UUID?
private var startTransactionDeadlineAt: TimeInterval?
/// Monotonic token captured by every async worker for one utterance.
private var utteranceGeneration: UInt64 = 0
/// Terminal delivery is idempotent: only the first path may write a result.
private var terminalUtteranceIds: Set<UUID> = []
/// Cursor context captured by the keyboard at the final insertion point.
private var pendingFieldContext: FlowFieldContext?
/// Dictation vs explicit last-input editing for the live utterance.
private var currentUtteranceMode: FlowUtteranceMode = .dictation
private var pendingEditSourceText: String?
private var pendingSourceHistoryEntryID: UUID?
private var pendingSourceHistoryEntryRevision: Int64?
private var pendingAIConversationID: UUID?
private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose?
private var pendingManagedOOBEFeature: ManagedGatewayOOBEFeature?
private var pendingProcessingDeadlineAt: TimeInterval?
private var pendingStopUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
private var lastHandledCommandSeq: Int64 = 0
/// Throttles AI-mode LLM draft writes into App Group.
private var aiAnswerStreamThrottle = AIAnswerStreamThrottle()
/// Published so Home / debug UI can show "recording" instead of a false "ready".
@Published private(set) var isUtteranceRecording = false
/// True from `stopped` until the result/error is written back to App Group.
@Published private(set) var isUtteranceProcessing = false
private var finalizeTask: Task<Void, Never>?
private var asrTask: Task<Void, Never>?
private let analyticsOperations = FlowAnalyticsOperationRegistry()
private var utteranceSafetyTask: Task<Void, Never>?
private var chunkedPipeline: ChunkedUtterancePipeline?
private var currentPartial = ""
private var lastFinal = ""
private var lastFinalWithPauseMarks = ""
/// Partial stitched text captured when the user stops recording.
private var bestPartialSnapshot = ""
/// Full utterance PCM for batch ASR fallback after pipelined chunking.
private var utterancePCMSamples: [Float] = []
private var chunkWarnings: [String] = []
private var asrCompletedGeneration: UInt64?
private var asrFailureMessage: String?
private var lastReadyTraceSignature = ""
private var lastCommandFingerprint = ""
private var lastIgnoredCommandSignature = ""
/// Wall-clock span of the current mic-open utterance (excludes LLM polish).
private var utteranceRecordingStartedAt: Date?
/// True while the host app scene is `.active` — drives foreground renewal.
private var isAppForeground = false
/// True while handling a keyboard-initiated `startflow` cold start.
private var isColdStartHandoff = false
/// User intent outlives a transient PiP loss; only explicit teardown clears it.
private var wantsActiveSession = false
/// Invalidates late async completions after retry, stop, or process teardown.
private var pipRecoveryOperation: UInt64 = 0
var shouldDeferHostHeavyWork: Bool {
isStarting
|| startingUtteranceId != nil
|| isUtteranceRecording
|| isUtteranceProcessing
|| hasUnacknowledgedTerminalResult()
}
func attachPiPHostView(_ view: UIView) {
pipController.attachHostView(view)
}
/// Guards the once-per-process launch reconciliation (scene reconnects
/// recreate the `@StateObject`-owned manager within the same process).
private static var didRunLaunchReconciliation = false
init(
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController(),
webPageContentService: WebPageContentService = .shared
) {
self.analyticsClient = analyticsClient
self.polisher = PolishingService(analyticsClient: analyticsClient)
self.pipController = pipController
self.webPageContentService = webPageContentService
// Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here — after a force-quit that would resurrect capture
// without the user re-opening.
//
// Launch reconciliation: a brand-new process can never own an
// in-flight session, so whatever the previous generation persisted
// (force-quit skips `applicationWillTerminate` entirely when the app
// was suspended) is void. Rotating the generation token also lets the
// keyboard invalidate stale ready snapshots instantly instead of
// waiting out the 60 s heartbeat-zombie window.
//
// Once per PROCESS, not per manager: iOS can disconnect and later
// reconnect the sole scene without killing the process, which
// recreates the `@StateObject` (and thus this init). Re-rotating then
// would wipe live state that belongs to this very process.
if AppGroup.isAvailable, !Self.didRunLaunchReconciliation {
Self.didRunLaunchReconciliation = true
let previous = FlowSessionBridge.rotateHostGeneration()
if previous != nil || FlowSessionBridge.isSessionActive() {
FlowSessionBridge.clearFlowStateOnHostLaunch()
FlowSessionDarwin.postSessionChanged()
debug("launch reconciliation: voided previous-generation Flow state")
}
}
capture.onEngineLiveChanged = { [weak self] live in
guard let self else { return }
self.refreshHostReady()
if !live, self.isUtteranceRecording {
self.failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
)
}
}
// A system interruption (call / Siri) stops audio frames mid-utterance;
// fail fast so the user is not silently recording into a gap.
capture.onInterruptionBegan = { [weak self] in
guard let self, self.isUtteranceRecording else { return }
self.failUtterance(
message: AppL10n.string("flow.error.recognitionInterrupted"),
kind: .recognitionInterrupted
)
}
pipController.onUnexpectedStop = { [weak self] in
guard let self, self.wantsActiveSession else { return }
self.handleUnexpectedPiPStop()
}
FlowTerminationCoordinator.register(self)
startDiagnosticsDumpObserver()
}
/// The keyboard extension is the process that witnesses "the session never
/// came up", but only the host knows why it never published ready. When the
/// keyboard writes a terminal start-failure report it pings us here so the
/// host's own breadcrumb window lands next to it in the same export.
private func startDiagnosticsDumpObserver() {
diagnosticsDumpObserver = FlowSessionDarwinObserver(
notificationName: FlowSessionDarwin.diagnosticsDumpNotificationName
) { [weak self] in
guard let self else { return }
guard let request = FlowFailureDiagnostics.consumeHostSnapshotRequest() else {
return
}
FlowDiagnostics.persistStartupFailure(
reason: "keyboardReported:\(request.reason)",
context: self.startupFailureContext(
trigger: "keyboardDumpRequest",
attemptCount: 0,
elapsed: Date().timeIntervalSince(request.requestedAt)
)
)
}
}
// MARK: - Public
/// Starts a Flow session: permissions → continuous capture → App Group active.
func startSession(
duration: TimeInterval? = nil,
coldStart: Bool = false,
reason: String = "unspecified"
) {
OSGDiag.log(
"startSession.request reason=\(reason) coldStart=\(coldStart) "
+ "active=\(isActive) starting=\(isStarting) \(OSGDiag.memoryTag())",
category: "flow"
)
traceState(
"startSession.request",
extra: "coldStart=\(coldStart) reason=\(reason)"
)
guard AppGroup.isAvailable else {
debug("cannot start flow session: App Group unavailable")
FlowDiagnostics.persistStartupFailure(
reason: "appGroupUnavailable",
context: startupFailureContext(
trigger: reason,
attemptCount: 0,
elapsed: 0
)
)
return
}
reconcilePersistedFlowStateBeforeStart()
// Healthy / busy sessions must not flash the cold-start overlay when a
// spurious `startflow` arrives (e.g. keyboard finalize race).
if coldStart, isActive {
extendSession(duration: duration)
refreshHostReady()
let decision = FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: true,
hostIsReady: FlowSessionBridge.isHostReady(),
isUtteranceBusy: isUtteranceRecording || isUtteranceProcessing
)
switch decision {
case .silence:
traceState(
"startSession.coldStart.silenced",
extra: FlowSessionBridge.isHostReady() ? "reason=alreadyReady" : "reason=busy"
)
return
case .present:
isColdStartHandoff = true
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
return
}
}
if coldStart {
isColdStartHandoff = true
}
if isActive {
wantsActiveSession = true
extendSession(duration: duration)
if pipController.isPictureInPictureActive {
pipLifecycleState = .active
refreshHostReady()
} else {
ensurePiPActive(
trigger: coldStart ? .coldStart : .existingSession,
duration: duration
)
}
return
}
wantsActiveSession = true
ensurePiPActive(
trigger: coldStart ? .coldStart : .foreground,
duration: duration
)
}
/// Clears App Group Flow state left behind when the host process was killed
/// or the device rebooted while the session flag was still set.
private func reconcilePersistedFlowStateBeforeStart() {
switch FlowOrphanRecordingReconciler.decide(
isHostStale: FlowSessionBridge.isHostStale(),
isActive: isActive,
recordingState: FlowSessionBridge.recordingState()
) {
case .clearZombieSession:
if isActive {
endSession()
} else {
FlowSessionBridge.clearFlowState()
}
debug("reconciled zombie persisted Flow state")
case .clearOrphanedRecording(let orphaned):
FlowSessionBridge.setRecordingState(.idle)
FlowSessionBridge.clearPendingTranscription()
debug("cleared orphaned keyboard recording state: \(orphaned.rawValue)")
case .none:
break
}
}
/// Foreground entry for Flow.
///
/// Default is **light** for audio work: automatically arm the low-profile
/// PiP, but do not
/// start capture or ASR. The transparent PiP is intentionally cheap; the
/// 170–220 MB capture/model path still starts only on explicit speech.
///
/// Pass `startCapture: true` for explicit user intent (Home “Start”, etc.).
/// Keyboard mic cold-start uses `osgkeyboard://startflow` → `startSession`.
func activateOnForeground(
reason: String = "unspecified",
startCapture: Bool = false
) {
FlowDiagnostics.log(
"activateOnForeground reason=\(reason) startCapture=\(startCapture) "
+ "onboardingDone=\(ProviderConfig.shared.hasCompletedOnboarding) "
+ "\(OSGDiag.memoryTag())"
)
guard AppGroup.isAvailable else {
FlowDiagnostics.log("activateOnForeground aborted reason=appGroupUnavailable")
FlowDiagnostics.persistStartupFailure(
reason: "appGroupUnavailable",
context: startupFailureContext(
trigger: "activateOnForeground:\(reason)",
attemptCount: 0,
elapsed: 0
)
)
return
}
guard AppPermissions.flowRequirementsMet else {
FlowDiagnostics.log("activateOnForeground aborted reason=permissions")
sessionWarning = permissionWarningMessage()
FlowSessionBridge.setHostReady(false)
FlowDiagnostics.persistStartupFailure(
reason: "permissionsMissing",
context: startupFailureContext(
trigger: "activateOnForeground:\(reason)",
attemptCount: 0,
elapsed: 0
)
)
return
}
startSession(
reason: !startCapture
? "activateOnForeground.autoPiP:\(reason)"
: "activateOnForeground:\(reason)"
)
}
/// User-requested recovery always discards the rejected AVKit generation.
func retryPiPRecovery() {
guard AppGroup.isAvailable else {
FlowDiagnostics.log("manual PiP recovery aborted reason=appGroupUnavailable")
FlowDiagnostics.persistStartupFailure(
reason: "appGroupUnavailable",
context: startupFailureContext(
trigger: FlowPiPActivationTrigger.manualRetry.rawValue,
attemptCount: 0,
elapsed: 0
)
)
return
}
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
FlowSessionBridge.setHostReady(false)
FlowDiagnostics.log("manual PiP recovery aborted reason=permissions")
FlowDiagnostics.persistStartupFailure(
reason: "permissionsMissing",
context: startupFailureContext(
trigger: FlowPiPActivationTrigger.manualRetry.rawValue,
attemptCount: 0,
elapsed: 0
)
)
return
}
wantsActiveSession = true
ensurePiPActive(trigger: .manualRetry, duration: nil, forceReset: true)
}
private func completeColdStartHandoff() {
isColdStartHandoff = false
if isActive {
refreshHostReady()
}
}
/// 强杀专用同步 teardown:不等待 ASR/LLM。
func prepareForProcessTermination() {
debug("prepareForProcessTermination")
if isUtteranceRecording || isUtteranceProcessing,
claimTerminal(utteranceId: currentUtteranceId) {
storeCurrentError(
AppL10n.string("flow.error.recognitionInterrupted"),
kind: .recognitionInterrupted,
status: .aborted
)
}
analyticsOperations.cancelAll()
wantsActiveSession = false
pipRecoveryOperation &+= 1
pipLifecycleState = .inactive
isColdStartHandoff = false
startTask?.cancel()
startTask = nil
startupAudioHealthTask?.cancel()
startupAudioHealthTask = nil
audioPrimeTask?.cancel()
audioPrimeTask = nil
audioPrimeID = nil
audioPrimeCancellationRequested = false
commandObserver = nil
pollingTask?.cancel()
pollingTask = nil
heartbeatTask?.cancel()
heartbeatTask = nil
levelTask?.cancel()
levelTask = nil
finalizeTask?.cancel()
finalizeTask = nil
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
asrTask?.cancel()
asrTask = nil
chunkedPipeline = nil
if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance()
// `asr` is a computed property that ALLOCATES a fresh ASRService
// when `sessionASR` is nil — never do that inside the ~5 s
// termination window; only cancel an instance that exists.
sessionASR?.cancel()
}
capture.cancelUtterance()
if capture.running {
capture.stop()
}
pipController.stop()
ScreenWakeLock.release()
sessionASR?.cancel()
sessionASR = nil
sessionASREngineMode = nil
sessionASRWarmedLocaleID = nil
if isActive || FlowSessionBridge.isSessionActive() {
FlowSessionBridge.markSessionInactive()
FlowSessionDarwin.postSessionChanged()
}
activeSessionId = nil
currentUtteranceId = nil
pendingFieldContext = nil
currentCommandSeq = 0
lastHandledCommandSeq = 0
isUtteranceRecording = false
isUtteranceProcessing = false
isActive = false
isStarting = false
sessionExpiresAt = nil
sessionWarning = nil
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
bestPartialSnapshot = ""
utterancePCMSamples = []
chunkWarnings = []
FlowSessionBridge.setHostReady(false)
}
func endSession() {
guard isActive || isStarting || wantsActiveSession else { return }
debug("Flow session ended")
if isUtteranceRecording || isUtteranceProcessing,
claimTerminal(utteranceId: currentUtteranceId) {
storeCurrentError(
AppL10n.string("flow.error.recognitionInterrupted"),
kind: .recognitionInterrupted,
status: .aborted
)
}
analyticsOperations.cancelAll()
wantsActiveSession = false
pipRecoveryOperation &+= 1
pipLifecycleState = .inactive
isColdStartHandoff = false
startTask?.cancel()
startTask = nil
startupAudioHealthTask?.cancel()
startupAudioHealthTask = nil
audioPrimeTask?.cancel()
audioPrimeTask = nil
audioPrimeID = nil
audioPrimeCancellationRequested = false
commandObserver = nil
pollingTask?.cancel()
pollingTask = nil
heartbeatTask?.cancel()
heartbeatTask = nil
levelTask?.cancel()
levelTask = nil
finalizeTask?.cancel()
finalizeTask = nil
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance()
asrTask?.cancel()
Task { await chunkedPipeline?.cancel() }
asr.cancel()
}
asrTask = nil
chunkedPipeline = nil
activeSessionId = nil
currentUtteranceId = nil
pendingFieldContext = nil
currentCommandSeq = 0
lastHandledCommandSeq = 0
isUtteranceRecording = false
isUtteranceProcessing = false
capture.stop()
pipController.stop()
ScreenWakeLock.release()
sessionASR = nil
sessionASREngineMode = nil
sessionASRWarmedLocaleID = nil
Task { await aiConversations.removeAll() }
FlowSessionBridge.markSessionInactive()
FlowSessionDarwin.postSessionChanged()
isActive = false
isStarting = false
sessionExpiresAt = nil
sessionWarning = nil
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
}
func extendSession(duration: TimeInterval? = nil) {
_ = duration
refreshHostReady()
}
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
func setAppForeground(_ foreground: Bool) {
isAppForeground = foreground
}
/// Full scene lifecycle — keeps Flow + ASR alive across app switches.
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .active:
setAppForeground(true)
resumeAfterForeground()
if wantsActiveSession, !pipController.isPictureInPictureActive {
ensurePiPActive(trigger: .foreground, duration: nil)
}
case .inactive:
setAppForeground(false)
writeHeartbeatIfActive()
if isActive, pipController.isPictureInPictureActive {
Task { @MainActor [weak self] in
await self?.pipController.prepareForBackgroundAutoStart()
}
} else if wantsActiveSession {
pausePiPRecoveryUntilForeground()
}
case .background:
setAppForeground(false)
if isActive, pipController.isPictureInPictureActive {
Task { @MainActor [weak self] in
await self?.pipController.prepareForBackgroundAutoStart()
}
} else if wantsActiveSession {
pausePiPRecoveryUntilForeground()
}
if isColdStartHandoff {
completeColdStartHandoff()
}
beginBackgroundKeepAlive()
@unknown default:
break
}
}
private func writeHeartbeatIfActive() {
guard isActive else { return }
FlowSessionBridge.writeHeartbeat()
}
private func beginBackgroundKeepAlive() {
guard isActive else { return }
FlowSessionBridge.writeHeartbeat()
}
private func handleUnexpectedPiPStop() {
guard wantsActiveSession else { return }
traceState("pip.unexpectedStop")
// Root cause of the "session never comes up while I stay in another
// app" report. Persist HERE, not later: without PiP there is nothing
// keeping this process alive, so iOS suspends it within seconds and the
// in-memory breadcrumb window dies with it. This is the one event that
// explains the whole failure, and it must survive the suspension.
reportPiPLossIfNeeded(origin: "unexpectedStop")
pipLifecycleState = .waitingForForeground
sessionWarning = nil
if isUtteranceRecording {
failUtterance(
message: AppL10n.string("flow.error.recognitionInterrupted"),
kind: .recognitionInterrupted
)
}
FlowSessionBridge.setHostReady(false)
refreshHostReady()
// Do not immediately fight another app for the system-owned PiP slot.
// The scene-active path or the existing heartbeat performs reconciliation.
}
private func pausePiPRecoveryUntilForeground() {
startTask?.cancel()
startTask = nil
pipRecoveryOperation &+= 1
pipController.resetGeneration()
isStarting = false
pipLifecycleState = .waitingForForeground
refreshHostReady()
reportPiPLossIfNeeded(origin: "recoveryPausedUntilForeground")
}
/// Catch-all: the home screen is showing a failure toast, so a report must
/// exist for it no matter which code path raised the warning.
private func reportSessionWarningIfUnreported(_ warning: String) {
// Cleared or replaced while we waited — whatever the user sees now will
// have triggered its own pass.
guard sessionWarning == warning else { return }
guard sessionWarningReportGate.shouldReport(
warning: warning,
lastExplicitReportAt: FlowFailureDiagnostics.lastReportPersistedAt(),
now: Date()
) else { return }
var context = startupFailureContext(
trigger: "sessionWarningToast",
attemptCount: 0,
elapsed: 0
)
context["warningMessage"] = warning
FlowDiagnostics.persistStartupFailure(
reason: "sessionWarningShown",
context: context
)
}
/// Persists the moment this process gives up on the PiP keep-alive.
///
/// Restarting PiP requires the scene to be active, so while the user stays
/// in another app this is terminal: the session cannot come back until they
/// open the app by hand. The keyboard cannot see the cause — it only sees
/// `ready=false` — so the host has to record it while it still can.
private func reportPiPLossIfNeeded(origin: String) {
guard wantsActiveSession, pipLossReportGate.shouldReport() else { return }
var context = startupFailureContext(
trigger: "pipLoss:\(origin)",
attemptCount: 0,
elapsed: 0
)
context["recoverableWithoutForeground"] = isAppForeground ? "true" : "false"
FlowDiagnostics.persistStartupFailure(
reason: isAppForeground ? "pipLostInForeground" : "pipLostWhileBackgrounded",
context: context
)
}
private func resumeAfterForeground() {
guard isActive else {
return
}
FlowSessionBridge.writeHeartbeat()
Task { @MainActor [weak self] in
await self?.reactivateCaptureIfNeeded()
// iOS `SpeechAnalyzer` is bundled with the OS; no
// on-device weights to reload after a background trip.
self?.bindSessionASRIfNeeded()
// ASR warmup stays on first mic press — never on foreground bounce.
}
}
private func reactivateCaptureIfNeeded() async {
guard isActive else { return }
// PiP releases the mic between utterances (and after drain while
// processing). Only reassert when an utterance is actively recording
// with capture already running — otherwise a foreground bounce was
// cold-starting the mic mid-finalize (`!pri` / session churn).
guard isUtteranceRecording, capture.running else {
refreshHostReady()
return
}
// A system interruption (call / Siri) may be in progress. Probe it:
// `setActive(true)` inside `reassertIfRunning` fails while the
// interruption is live and succeeds once it ends — which also covers
// the documented case where iOS never delivers `.ended` (the latch
// must not depend on that notification, or the session is dead until
// its TTL). While the probe fails we deliberately do NOT stop or
// rebuild: tearing the engine down would remove the observers the
// `.ended` rebuild relies on and churn the shared session mid-call.
if capture.isInterrupted {
guard await capture.reassertIfRunning(), !capture.isInterrupted else { return }
}
if capture.running {
let reasserted = await capture.reassertIfRunning()
if reasserted, await capture.awaitAudioFlowing(timeout: 2) {
sessionWarning = nil
refreshHostReady()
return
}
// The await above is a suspension point: the session may have
// ended (expiry, user, teardown) while we waited. Never restart
// the microphone for a session that no longer exists.
guard isActive, !Task.isCancelled else { return }
// Never tear capture down underneath a live utterance either — a
// stalled route transition mid-recording must surface through the
// utterance pipeline (safety timer / empty-transcript error), not
// as a silent stop that truncates the take with no error at all.
guard !isUtteranceRecording, !isUtteranceProcessing else {
refreshHostReady()
return
}
// Reassert failed, or the engine reports running yet produces no
// frames (zombie after suspend / mediaserverd reset) — fall
// through to a full rebuild instead of leaving it half-dead.
capture.stop()
debug("capture zombie after foreground — rebuilding")
}
do {
try await capture.start()
sessionWarning = nil
debug("capture restarted after foreground")
refreshHostReady()
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
sessionWarning = message
debug("capture restart failed: \(message)")
refreshHostReady()
FlowDiagnostics.persistStartupFailure(
reason: "captureRestartFailed",
context: startupFailureContext(
trigger: "resumeAfterForeground",
attemptCount: 0,
elapsed: 0
).merging(
["captureError": Self.safeErrorLogMetadata(error)]
) { _, new in new }
)
}
}
/// Publish whether the keyboard can start a new utterance without jumping to the host app.
private func refreshHostReady() {
guard isActive else {
// Not active yet. Distinguish "no host session at all" from
// "received a start request and warming up" so the keyboard can
// tell an alive-but-starting host from a dead one and hold its
// start budget instead of timing out. When the start is parked
// because the app is not foreground (PiP cannot arm from the
// background), advertise `.waitingForForeground` so the keyboard
// prompts the user to bring the app forward.
let startupReason: FlowReadySnapshot.Reason
if wantsActiveSession {
startupReason = isAppForeground ? .starting : .waitingForForeground
} else {
startupReason = .noSession
}
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: activeSessionId,
ready: false,
reason: startupReason,
heartbeatAt: Date().timeIntervalSince1970,
engineMode: store.engineMode,
localeId: store.localeId,
hostGeneration: FlowSessionBridge.currentHostGeneration()
)
)
return
}
// Invariant: an active host (this process owns a live session) must keep
// the App Group `flowSessionActive` flag set. It can be violated from the
// outside — a host suspended after a PiP drop stops writing heartbeats, so
// the keyboard's `clearIfHostStale()` reads it as dead and clears the flag
// while the process is merely asleep. On resume `isActive` is still true,
// but `isHostReachable()`/`isHostReady()` stay false forever because they
// gate on that flag, so every startflow is rejected as
// `coldStartHostReadyNotPublished`. Re-assert it here — the single publish
// chokepoint every path (and the 1 s heartbeat loop) funnels through — so
// the divergence heals within a second, before the user presses mic.
if !FlowSessionBridge.isSessionActive() {
FlowSessionBridge.reassertSessionActive()
FlowSessionDarwin.postSessionChanged()
debug("re-asserted flowSessionActive flag cleared by keyboard stale-host cleanup")
}
let pollingAlive = pollingTask != nil && pollingTask?.isCancelled != true
let hasRecentAudio = capture.engineHasRecentAudio(maxAge: 2)
let hasPendingDelivery = hasUnacknowledgedTerminalResult()
let canAcceptUtterance = pipController.isPictureInPictureActive
&& pollingAlive
&& startingUtteranceId == nil
&& !isUtteranceRecording
&& !isUtteranceProcessing
&& sessionWarning == nil
&& !capture.isInterrupted
&& !hasPendingDelivery
let reason: FlowReadySnapshot.Reason
if canAcceptUtterance {
reason = .ready
} else if startingUtteranceId != nil {
reason = .waitingForAudioProof
} else if sessionWarning != nil {
reason = .error
} else if isUtteranceRecording {
reason = .recording
} else if isUtteranceProcessing {
reason = .processing
} else if hasPendingDelivery {
reason = .awaitingDelivery
} else if !pipController.isPictureInPictureActive {
reason = .starting
} else if pipController.isPictureInPictureActive {
// PiP is already up — transient gates (!polling / interruption)
// are not a cold start. Prefer awaitingDelivery-adjacent idle over
// `.starting` so the keyboard never flashes a false “starting” state.
reason = .awaitingDelivery
} else {
reason = .starting
}
let now = Date().timeIntervalSince1970
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: activeSessionId,
ready: canAcceptUtterance,
reason: reason,
heartbeatAt: now,
readyAt: canAcceptUtterance ? now : nil,
audioProofAt: hasRecentAudio ? now : nil,
engineMode: store.engineMode,
localeId: store.localeId,
busyUtteranceId: startingUtteranceId
?? (isUtteranceRecording || isUtteranceProcessing
? currentUtteranceId
: (hasPendingDelivery ? FlowSessionBridge.latestResult()?.utteranceId : nil)),
hostGeneration: FlowSessionBridge.currentHostGeneration()
)
)
let signature = [
canAcceptUtterance ? "ready=1" : "ready=0",
"reason=\(reason.rawValue)",
capture.engineIsLive ? "engine=live" : "engine=dead",
hasRecentAudio ? "audio=fresh" : "audio=stale",
isUtteranceRecording ? "recording=1" : "recording=0",
isUtteranceProcessing ? "processing=1" : "processing=0",
startingUtteranceId == nil ? "starting=0" : "starting=1",
sessionWarning == nil ? "warning=0" : "warning=1"
].joined(separator: "|")
if signature != lastReadyTraceSignature {
lastReadyTraceSignature = signature
traceState("hostReady.update", extra: signature)
}
}
/// Home preview field gained focus while this app is the Flow host.
/// Reactivate capture and refresh the App Group ready contract so the
/// custom keyboard extension sees green immediately.
func refreshForInlineKeyboardFocus() async {
guard isActive else { return }
refreshHostReady()
FlowSessionBridge.writeHeartbeat()
}
// MARK: - Session start
private func ensurePiPActive(
trigger: FlowPiPActivationTrigger,
duration: TimeInterval?,
forceReset: Bool = false
) {
switch FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: wantsActiveSession,
isAppForeground: isAppForeground,
isPictureInPictureActive: pipController.isPictureInPictureActive
) {
case .noSessionIntent:
// Silent before: the keyboard just waited out its 8 s budget with
// nothing on either timeline explaining why.
traceState(
"pipRecovery.skipped",
extra: "trigger=\(trigger.rawValue) reason=noSessionIntent"
)
return
case .alreadyActive:
pipLifecycleState = .active
sessionWarning = nil
if isActive {
refreshHostReady()
} else {
activateFlowSessionAfterPiPProof(duration: duration)
}
handleColdStartAfterSessionReady()
return
case .waitForForeground:
pipLifecycleState = .waitingForForeground
FlowSessionBridge.setHostReady(false)
refreshHostReady()
// The most likely cold-start miss: the URL was routed before iOS
// reported the scene as active, so the start is deferred and the
// keyboard sees nothing but `hostReady=false` until it times out.
traceState(
"pipRecovery.deferred",
extra: "trigger=\(trigger.rawValue) reason=waitForForeground"
)
return
case .startRecovery:
break
}
guard startTask == nil else {
traceState(
"pipRecovery.coalesced",
extra: "trigger=\(trigger.rawValue)"
)
return
}
// Claim the flag synchronously: on a cold start the URL router and
// `activateOnForeground()` both fire in the same runloop turn, and
// setting it inside the async body let two start bodies interleave.
isStarting = true
pipRecoveryOperation &+= 1
let operation = pipRecoveryOperation
let recoveringExistingSession = isActive
let resetFirstGeneration = forceReset
|| recoveringExistingSession
|| pipController.generation > 0
pipLifecycleState = recoveringExistingSession
? .recovering(attempt: 1, total: FlowPiPRecoveryPolicy.maxAttempts)
: .preparing(attempt: 1, total: FlowPiPRecoveryPolicy.maxAttempts)
startTask = Task { @MainActor [weak self] in
await self?.startSessionAsync(
duration: duration,
trigger: trigger,
operation: operation,
resetFirstGeneration: resetFirstGeneration
)
}
}
/// Sleeps until the next scheduled PiP attempt, waking early once the
/// system media server finishes a reset so the retry lands right away.
private func waitForNextPiPAttempt(seconds: TimeInterval) async {
let wakeAt = Date().addingTimeInterval(seconds)
let resetCount = FlowAudioSessionCoordinator.shared.mediaServicesResetCount
while Date() < wakeAt, !Task.isCancelled {
if FlowAudioSessionCoordinator.shared.mediaServicesResetCount != resetCount {
traceState("pipRecovery.wokeOnMediaServicesReset")
return
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
}
private func startSessionAsync(
duration: TimeInterval?,
trigger: FlowPiPActivationTrigger,
operation: UInt64,
resetFirstGeneration: Bool
) async {
// `isStarting` was claimed synchronously in `ensurePiPActive()`.
defer {
if operation == pipRecoveryOperation {
isStarting = false
startTask = nil
}
}
guard !Task.isCancelled else { return }
traceState("startSessionAsync.begin", extra: "trigger=\(trigger.rawValue)")
sessionWarning = nil
// Advertise "warming" the moment arming begins. PiP arming can take up
// to `FlowPiPRecoveryPolicy.totalBudget`; without this the keyboard sees
// `.noSession` for that whole window and cannot tell it apart from a
// dead host, so it burns its start budget in the dark.
refreshHostReady()
let recoveryStartedAt = Date()
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
pipLifecycleState = .failed(.notPossible)
traceState("startSessionAsync.blocked", extra: "reason=permissions")
FlowSessionBridge.setHostReady(false)
isColdStartHandoff = false
FlowDiagnostics.persistStartupFailure(
reason: "permissionsMissing",
context: startupFailureContext(
trigger: trigger.rawValue,
attemptCount: 0,
elapsed: Date().timeIntervalSince(recoveryStartedAt)
)
)
return
}
let attemptsStartedAt = Date()
let deadline = attemptsStartedAt.addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
var lastFailure: FlowPiPStartFailure = .timedOut
var attemptsPerformed = 0
for attempt in 1...FlowPiPRecoveryPolicy.maxAttempts {
guard FlowPiPRecoveryPolicy.canContinue(
operation: operation,
currentOperation: pipRecoveryOperation,
wantsActiveSession: wantsActiveSession,
isAppForeground: isAppForeground,
taskIsCancelled: Task.isCancelled
) else {
if operation == pipRecoveryOperation,
wantsActiveSession,
!Task.isCancelled,
!isAppForeground {
pipLifecycleState = .waitingForForeground
FlowSessionBridge.setHostReady(false)
}
traceState(
"startSessionAsync.aborted",
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) "
+ "at=loopTop reason=\(pipRecoveryAbortReason(operation: operation))"
)
return
}
let wait = FlowPiPRecoveryPolicy.attemptOffset(beforeAttempt: attempt)
- Date().timeIntervalSince(attemptsStartedAt)
if wait > 0 {
await waitForNextPiPAttempt(seconds: wait)
}
guard FlowPiPRecoveryPolicy.canContinue(
operation: operation,
currentOperation: pipRecoveryOperation,
wantsActiveSession: wantsActiveSession,
isAppForeground: isAppForeground,
taskIsCancelled: Task.isCancelled
) else {
traceState(
"startSessionAsync.aborted",
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) "
+ "at=afterRetryDelay reason=\(pipRecoveryAbortReason(operation: operation))"
)
return
}
if resetFirstGeneration || attempt > 1 {
pipController.resetGeneration()
}
let remaining = deadline.timeIntervalSinceNow
guard remaining > 0 else { break }
attemptsPerformed = attempt
pipLifecycleState = isActive
? .recovering(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
: .preparing(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
traceState(
"pipRecovery.attempt",
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) generation=\(pipController.generation)"
)
let outcome = await pipController.startAndWait(
hostTimeout: FlowPiPRecoveryPolicy.hostTimeout(
attempt: attempt,
remainingBudget: remaining
),
activeTimeout: FlowPiPRecoveryPolicy.activeTimeout(
remainingBudget: deadline.timeIntervalSinceNow
)
)
let recoveryElapsed = FlowPiPRecoveryPolicy.totalBudget
- max(0, deadline.timeIntervalSinceNow)
guard FlowPiPRecoveryPolicy.canContinue(
operation: operation,
currentOperation: pipRecoveryOperation,
wantsActiveSession: wantsActiveSession,
isAppForeground: isAppForeground,
taskIsCancelled: Task.isCancelled
) else {
traceState(
"startSessionAsync.aborted",
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) "
+ "at=afterStartAndWait outcome=\(outcome) "
+ "reason=\(pipRecoveryAbortReason(operation: operation))"
)
return
}
switch outcome {
case .started:
sessionWarning = nil
pipLifecycleState = .active
if isActive {
refreshHostReady()
} else {
activateFlowSessionAfterPiPProof(duration: duration)
}
traceState(
"startSessionAsync.ready",
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) generation=\(pipController.generation)"
)
handleColdStartAfterSessionReady()
debug("Flow session started (PiP keep-alive), mic released between utterances")
return
case .failed(let failure):
lastFailure = failure
traceState(
"startSessionAsync.failed",
extra: "reason=pipUnavailable trigger=\(trigger.rawValue) "
+ "attempt=\(attempt) failure=\(failure) "
+ "elapsed=\(String(format: "%.2f", recoveryElapsed))"
)
}
}
pipController.resetGeneration()
pipLifecycleState = .failed(lastFailure)
sessionWarning = AppL10n.string(lastFailure.localizationKey)
FlowSessionBridge.setHostReady(false)
refreshHostReady()
debug("PiP keep-alive failed after bounded recovery: \(lastFailure)")
FlowDiagnostics.persistStartupFailure(
reason: String(describing: lastFailure),
context: startupFailureContext(
trigger: trigger.rawValue,
attemptCount: attemptsPerformed,
elapsed: Date().timeIntervalSince(recoveryStartedAt)
)
)
}
private func activateFlowSessionAfterPiPProof(duration: TimeInterval?) {
let sessionId = activeSessionId ?? UUID()
activeSessionId = sessionId
lastHandledCommandSeq = 0
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId)
FlowSessionDarwin.postSessionChanged()
isActive = true
wantsActiveSession = true
pipLifecycleState = .active
// Low-profile PiP is a system-owned keep-alive surface. Keeping the
// display awake wastes far more power than the static PiP itself.
ScreenWakeLock.release()
sessionExpiresAt = nil
startHeartbeat()
startCommandObserver()
startPolling()
startLevelPublishing()
bindSessionASRIfNeeded()
// ASR warmup deferred to beginUtterance (first mic press).
refreshHostReady()
scheduleStartupAudioHealthCheck()
traceState("activateFlowSessionAfterPiPProof.done")
}
private func scheduleStartupAudioHealthCheck() {
guard !didRunStartupAudioHealthCheck else { return }
didRunStartupAudioHealthCheck = true
startupAudioHealthTask?.cancel()
startupAudioHealthTask = Task { @MainActor [weak self] in
guard let self else { return }
// Let deterministic Rime deployment claim the launch memory peak.
// The health probe is opportunistic and must never delay app UI.
try? await Task.sleep(nanoseconds: 300_000_000)
let heavyDeadline = Date().addingTimeInterval(8)
while FlowSessionBridge.isHostHeavy(), Date() < heavyDeadline {
guard self.isActive, !Task.isCancelled else { return }
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard self.isActive,
!Task.isCancelled,
AppPermissions.flowRequirementsMet,
!FlowSessionBridge.isHostHeavy(),
self.startingUtteranceId == nil,
!self.isUtteranceRecording,
!self.isUtteranceProcessing,
!self.hasUnacknowledgedTerminalResult() else {
FlowDiagnostics.log("startup audio health check skipped")
return
}
await self.runStartupAudioHealthCheck()
}
}
private func runStartupAudioHealthCheck() async {
let healthID = UUID()
startAudioPrime(id: healthID, origin: "startupHealth")
guard let task = audioPrimeTask else { return }
_ = await task.value
guard isActive, !Task.isCancelled else { return }
// If a user utterance or touch prime took ownership, it has priority.
guard audioPrimeID == healthID,
startingUtteranceId == nil,
!isUtteranceRecording,
!isUtteranceProcessing else {
FlowDiagnostics.log("startup audio health check adopted by user")
return
}
var flowing = await capture.awaitAudioFlowing(timeout: 1.2)
if !flowing {
// One bounded rebuild exercises the same soft-dead recovery as a
// real first utterance, before the user is waiting on it.
capture.stop(releaseSession: false)
audioPrimeTask = nil
startAudioPrime(id: healthID, origin: "startupHealthRebuild")
if let rebuild = audioPrimeTask {
_ = await rebuild.value
flowing = await capture.awaitAudioFlowing(timeout: 1.0)
}
}
guard audioPrimeID == healthID,
startingUtteranceId == nil,
!isUtteranceRecording,
!isUtteranceProcessing else {
FlowDiagnostics.log("startup audio health rebuild adopted by user")
return
}
audioPrimeID = nil
audioPrimeTask = nil
audioPrimeCancellationRequested = false
if capture.running {
capture.stop(releaseSession: false)
}
_ = await pipController.reassertKeepAliveAudioSession()
refreshHostReady()
FlowDiagnostics.log(
"startup audio health check done flowing=\(flowing ? 1 : 0)"
)
}
private func prepareExistingSessionForColdStartReturn() async {
guard isColdStartHandoff, isActive else { return }
sessionWarning = nil
if !pipController.isPictureInPictureActive {
ensurePiPActive(trigger: .existingSession, duration: nil)
return
}
pipLifecycleState = .active
refreshHostReady()
handleColdStartAfterSessionReady()
}
@MainActor
private func handleColdStartAfterSessionReady() {
guard isColdStartHandoff, isActive else { return }
refreshHostReady()
guard FlowSessionBridge.isHostReady() else {
// Busy ≠ broken: a startflow arriving mid-utterance finds a healthy
// session that is simply recording/processing. Treating it as a
// PiP failure could restart capture and kill the live utterance.
if isUtteranceRecording || isUtteranceProcessing {
completeColdStartHandoff()
debug("cold-start handoff ignored: session busy with an utterance")
return
}
let message = AppL10n.string("flow.session.error.notPossible")
sessionWarning = message
FlowSessionBridge.setHostReady(false)
if !pipController.isPictureInPictureActive {
scheduleColdStartRecovery(duration: nil)
}
debug("cold-start blocked: host ready contract not published")
// This is the toast the user reports as "cannot start voice
// session": the keyboard jumped us here and we still could not
// publish ready.
FlowDiagnostics.persistStartupFailure(
reason: "coldStartHostReadyNotPublished",
context: startupFailureContext(
trigger: "coldStartHandoff",
attemptCount: 0,
elapsed: 0
)
)
return
}
let hostEntry = HostReturnService.pendingHostEntry()
guard hostEntry != nil else {
completeColdStartHandoff()
return
}
scheduleAutoReturnToHostIfNeeded(hostEntry: hostEntry)
}
private func scheduleAutoReturnToHostIfNeeded(hostEntry: HostAppEntry?) {
let skipSwitch = true
guard skipSwitch, hostEntry != nil else { return }
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 450_000_000)
guard let self, self.isColdStartHandoff, self.isActive,
FlowSessionBridge.isHostReady() else { return }
if HostReturnService.openPendingHostIfPossible() {
self.completeColdStartHandoff()
}
}
}
/// Actively rebuilds the audio pipeline after a failed cold start instead
/// of passively waiting for frames that a dead engine will never produce.
/// Escalates per attempt: reassert the session → full engine rebuild →
/// bounce the audio session and rebuild. Force-quit relaunches routinely
/// inherit stale mediaserverd state that only a rebuild clears.
private func scheduleColdStartRecovery(duration: TimeInterval?) {
// The failed controller has been fully torn down. Give the app
// scene and Pegasus service a short settling interval before the
// one bounded cold-start retry creates a new controller generation.
ensurePiPActive(
trigger: .startupRecovery,
duration: duration,
forceReset: true
)
}
private func bindSessionASRIfNeeded(force: Bool = false) {
let engineMode = store.engineMode
if !force,
sessionASR != nil,
sessionASREngineMode == engineMode {
return
}
sessionASR?.cancel()
sessionASR = ASRServiceFactory.make(store: store)
sessionASREngineMode = engineMode
sessionASRWarmedLocaleID = nil
}
private func scheduleASRWarmup() {
let localeID = SpeechLocaleResolver.resolve(store.localeId).identifier(.bcp47)
guard sessionASRWarmedLocaleID != localeID else { return }
guard HostMemoryBudget.gate("asr.warmup", category: "asr") else { return }
OSGDiag.log("scheduleASRWarmup \(OSGDiag.memoryTag())", category: "asr")
FlowSessionBridge.setHostHeavy(true)
Task { @MainActor [weak self] in
defer { FlowSessionBridge.setHostHeavy(false) }
await self?.warmupASRIfNeeded()
}
}
private func warmupASRIfNeeded() async {
OSGDiag.log("warmupASRIfNeeded.begin \(OSGDiag.memoryTag())", category: "asr")
bindSessionASRIfNeeded()
let locale = SpeechLocaleResolver.resolve(store.localeId)
let localeID = locale.identifier(.bcp47)
guard sessionASRWarmedLocaleID != localeID else {
OSGDiag.log("warmupASRIfNeeded.skip alreadyWarmed=\(localeID)", category: "asr")
return
}
await asr.warmup(locale: locale)
sessionASRWarmedLocaleID = localeID
FlowDiagnostics.log("ASR warmup complete locale=\(localeID)")
OSGDiag.log("warmupASRIfNeeded.done locale=\(localeID) \(OSGDiag.memoryTag())", category: "asr")
}
private func permissionWarningMessage() -> String {
if AppPermissions.micStatus != .granted {
return AppL10n.string("flow.error.micRequired")
}
return AppL10n.string("flow.error.speechRequired")
}
// MARK: - Polling
private func startCommandObserver() {
commandObserver = FlowSessionDarwinObserver(
notificationName: FlowSessionDarwin.commandNotificationName
) { [weak self] in
self?.handleKeyboardSignal()
}
}
private func startPolling() {
pollingTask?.cancel()
lastObservedRecordingState = FlowSessionBridge.recordingState()
FlowDiagnostics.log(
"polling started: initialRecordingState=\(lastObservedRecordingState.rawValue) " +
"container=\(AppGroup.containerPathForDiagnostics)"
)
pollingTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.handleKeyboardSignal()
try? await Task.sleep(nanoseconds: 500_000_000)
}
}
}
private func handleKeyboardSignal() {
FlowSessionBridge.reloadFromDisk()
consumeHistoryMutationOutbox()
var commands = FlowSessionBridge.commands(after: lastHandledCommandSeq)
if commands.isEmpty, let latest = FlowSessionBridge.latestCommand(),
latest.commandSeq > lastHandledCommandSeq {
commands = [latest]
}
guard !commands.isEmpty else {
lastCommandFingerprint = ""
consumeAckIfNeeded()
return
}
for command in commands {
let fingerprint = "\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(command.action.rawValue)|\(command.commandSeq)"
guard fingerprint != lastCommandFingerprint else { continue }
lastCommandFingerprint = fingerprint
handleFlowCommand(command)
}
consumeAckIfNeeded()
}
private func consumeHistoryMutationOutbox() {
for mutation in HistoryMutationOutbox.pending() {
let entry = SpeechHistoryStore.shared.applyHistoryMutation(mutation)
if mutation.usageCategory == .ai, let text = mutation.text {
UsageStatisticsStore.shared.recordAIInsertion(
text: text,
commitID: mutation.id
)
}
HistoryMutationReceiptStore.save(
HistoryMutationReceipt(
mutationID: mutation.id,
entryID: entry?.id,
revision: entry?.revision
)
)
HistoryMutationOutbox.acknowledge(mutation.id)
}
}
private func consumeAckIfNeeded() {
guard let ack = FlowSessionBridge.latestAck(),
let result = FlowSessionBridge.latestResult(),
ack.sessionId == result.sessionId,
ack.utteranceId == result.utteranceId,
ack.commandSeq == result.commandSeq,
ack.revision == nil
|| result.revision == nil
|| ack.revision == result.revision else {
return
}
FlowSessionBridge.clearResult()
// Ack clears the terminal result; republish ready immediately so the
// keyboard does not linger on awaitingDelivery / starting between the
// 500 ms poll and the next heartbeat. If processing is still true for
// this same utterance, the writer forgot to drop the gate — heal here.
if FlowHostAckGatePolicy.shouldDropProcessingGate(
ackUtteranceId: ack.utteranceId,
currentUtteranceId: currentUtteranceId,
isUtteranceProcessing: isUtteranceProcessing
) {
completeFinalizeCleanup(
sessionId: ack.sessionId,
utteranceId: ack.utteranceId
)
} else {
refreshHostReady()
}
}
private func hasUnacknowledgedTerminalResult() -> Bool {
guard let result = FlowSessionBridge.latestResult(),
result.status == .final
|| result.status == .error
|| result.status == .aborted
|| result.status == .timeout else {
return false
}
guard let ack = FlowSessionBridge.latestAck() else { return true }
return ack.sessionId != result.sessionId
|| ack.utteranceId != result.utteranceId
|| ack.commandSeq != result.commandSeq
}
private var hostUtteranceState: FlowHostUtteranceState {
if let startingUtteranceId {
return .starting(startingUtteranceId)
}
if isUtteranceRecording, let currentUtteranceId {
return .recording(currentUtteranceId)
}
if isUtteranceProcessing, let currentUtteranceId {
return .processing(currentUtteranceId)
}
return .idle
}
private func storeRejectedStart(
_ command: FlowCommand,
message: String,
status: FlowResult.Status
) {
FlowSessionBridge.writeResult(
FlowResult(
sessionId: command.sessionId,
utteranceId: command.utteranceId,
commandSeq: command.commandSeq,
status: status,
text: message,
errorKind: .audioUnavailable,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
utteranceMode: command.utteranceMode,
aiConversationID: command.aiConversationID
)
)
traceState(
"startRecording.rejected",
extra: "status=\(status.rawValue) utterance=\(command.utteranceId.uuidString.prefix(8))"
)
}
private func handleFlowCommand(_ command: FlowCommand) {
switch FlowCommandGatekeeper.decide(
commandSessionId: command.sessionId,
commandSeq: command.commandSeq,
activeSessionId: activeSessionId,
lastHandledCommandSeq: lastHandledCommandSeq
) {
case .rejectWrongSession:
traceIgnoredCommand(
reason: "staleSession",
command: command,
detail: "commandSession=\(command.sessionId)"
)
return
case .rejectStaleSeq:
traceIgnoredCommand(
reason: "seqNotIncreasing",
command: command,
detail: "last=\(lastHandledCommandSeq)"
)
return
case .accept:
break
}
lastHandledCommandSeq = command.commandSeq
lastIgnoredCommandSignature = ""
FlowDiagnostics.log(
"command \(command.action.rawValue) seq=\(command.commandSeq) "
+ "utterance=\(command.utteranceId) "
+ "mode=\(command.resolvedUtteranceMode.rawValue) "
+ "editSourceChars=\(command.editSourceText?.count ?? 0)"
)
switch command.action {
case .startRecording:
guard command.resolvedUtteranceMode != .unsupportedLegacy else {
storeRejectedStart(
command,
message: AppL10n.string("flow.error.recognitionInterrupted"),
status: .error
)
traceState(
"startRecording.rejected",
extra: "reason=unsupportedLegacyMode"
)
return
}
let startDecision = FlowStartTransactionPolicy.decide(
incomingUtteranceID: command.utteranceId,
deadlineAt: command.startDeadlineAt,
hostState: hostUtteranceState
)
switch startDecision {
case .idempotent:
traceState(
"startRecording.idempotent",
extra: "utterance=\(command.utteranceId.uuidString.prefix(8))"
)
refreshHostReady()
return
case .rejectBusy:
storeRejectedStart(
command,
message: AppL10n.string("flow.error.recognitionInterrupted"),
status: .error
)
return
case .rejectExpired:
storeRejectedStart(
command,
message: AppL10n.string("flow.coldStart.error.audioTimeout"),
status: .timeout
)
return
case .accept:
break
}
guard prepareUtteranceIdentity(
utteranceId: command.utteranceId,
commandSeq: command.commandSeq
) else { return }
let deadlineAt = command.startDeadlineAt
?? Date().timeIntervalSince1970 + FlowSessionKeys.utteranceStartBudget
startingUtteranceId = command.utteranceId
startTransactionDeadlineAt = deadlineAt
FlowSessionBridge.writeStartTransaction(
FlowStartTransaction(
sessionID: command.sessionId,
utteranceID: command.utteranceId,
deadlineAt: deadlineAt,
phase: .starting
)
)
currentUtteranceMode = command.resolvedUtteranceMode
pendingManagedRequestPurpose = command.managedRequestPurpose
pendingManagedOOBEFeature = command.managedOOBEFeature
pendingAIConversationID = currentUtteranceMode == .aiQuestion
? command.aiConversationID
: nil
if currentUtteranceMode == .editLastInput {
let source = command.editSourceText?
.trimmingCharacters(in: .whitespacesAndNewlines)
pendingEditSourceText = source?.isEmpty == false ? source : nil
pendingSourceHistoryEntryID = command.sourceHistoryEntryID
pendingSourceHistoryEntryRevision = command.sourceHistoryEntryRevision
} else {
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
}
guard let startUtteranceId = currentUtteranceId else { return }
let startToken = FlowUtteranceStartToken(
generation: utteranceGeneration,
utteranceId: startUtteranceId
)
Task { @MainActor [weak self] in
await self?.handleStartRecordingCommand(
utteranceId: command.utteranceId,
commandSeq: command.commandSeq,
startToken: startToken,
deadlineAt: deadlineAt
)
}
case .stopRecording:
guard currentUtteranceId == command.utteranceId else { return }
pendingFieldContext = command.fieldContext
pendingProcessingDeadlineAt = command.processingDeadlineAt
?? (currentUtteranceMode == .editLastInput
? Date().timeIntervalSince1970
+ FlowSessionKeys.editLastInputHostProcessingBudget
: nil)
FlowDiagnostics.log(
"field context received before/after=" +
"\(command.fieldContext?.precedingText?.count ?? 0)/" +
"\(command.fieldContext?.followingText?.count ?? 0)"
)
if isUtteranceRecording {
endUtterance()
} else if !isUtteranceProcessing {
// A very short press can place start+stop in the journal before
// the async capture-start task gets a MainActor turn.
pendingStopUtteranceId = command.utteranceId
debug("stop command queued until capture start")
}
case .abort:
guard currentUtteranceId == command.utteranceId else { return }
abortUtterance()
case .prewarm:
// No utterance identity — warm SpeechAnalyzer / cloud prep only.
scheduleASRWarmup()
FlowDiagnostics.log("prewarm ASR requested seq=\(command.commandSeq)")
case .primeAudio:
beginAudioPrime(command)
case .cancelPrimeAudio:
cancelAudioPrime(command)
case .endAIConversation:
if currentUtteranceMode == .aiQuestion,
isUtteranceProcessing || isUtteranceRecording {
abortUtterance()
}
if let conversationID = command.aiConversationID {
Task { await aiConversations.removeConversation(conversationID) }
}
case .submitAIQuestion:
guard command.resolvedUtteranceMode == .aiQuestion else {
storeRejectedStart(
command,
message: AppL10n.string("flow.error.aiQuestionFailed"),
status: .error
)
return
}
let question = command.aiQuestionText?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !question.isEmpty else {
storeRejectedStart(
command,
message: AppL10n.string("flow.error.aiQuestionFailed"),
status: .error
)
return
}
let startDecision = FlowStartTransactionPolicy.decide(
incomingUtteranceID: command.utteranceId,
deadlineAt: command.startDeadlineAt,
hostState: hostUtteranceState
)
switch startDecision {
case .idempotent:
refreshHostReady()
return
case .rejectBusy, .rejectExpired:
let status: FlowResult.Status =
startDecision == .rejectExpired ? .timeout : .error
storeRejectedStart(
command,
message: AppL10n.string("flow.error.recognitionInterrupted"),
status: status
)
return
case .accept:
break
}
guard prepareUtteranceIdentity(
utteranceId: command.utteranceId,
commandSeq: command.commandSeq
) else { return }
currentUtteranceMode = .aiQuestion
pendingManagedRequestPurpose = command.managedRequestPurpose
pendingManagedOOBEFeature = command.managedOOBEFeature
pendingAIConversationID = command.aiConversationID
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
startingUtteranceId = nil
startTransactionDeadlineAt = nil
FlowSessionBridge.clearStartTransaction()
isUtteranceRecording = false
isUtteranceProcessing = true
refreshHostReady()
let conversationID = command.aiConversationID
let utteranceId = command.utteranceId
let commandSeq = command.commandSeq
let sessionId = command.sessionId
let taskKind = command.aiTaskKind ?? .aiQuestion
let thinkingEnabled = command.aiThinkingEnabled ?? true
Task { @MainActor [weak self] in
await self?.answerPrefilledAIQuestion(
request: PrefilledAIRequest(
question: question,
conversationID: conversationID,
webPageURL: command.aiWebPageURL,
taskKind: taskKind,
requestSource: command.managedRequestSource,
requestPurpose: command.managedRequestPurpose,
oobeFeature: command.managedOOBEFeature,
thinkingEnabled: thinkingEnabled
),
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
}
}
}
/// Clipboard body for an explicitly spoken clipboard request. Opt-in
/// history is the authorization gate, and while the keyboard is visible its
/// newest stored item is the live pasteboard. Unlike the idle hint cards,
/// naming the clipboard out loud is not bound to the 30s hint window.
private func clipboardMaterialForAIQuestion(store: AppGroupStore) -> String? {
guard store.clipboardHistoryEnabled else { return nil }
return ClipboardHistoryStore().newestEntry?.text
}
/// Skip ASR and answer a prefilled AI hint / typed question.
private func answerPrefilledAIQuestion(
request: PrefilledAIRequest,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64
) async {
// Hint-card / prefilled questions never go through `finalizeUtterance`,
// so this path must drop the processing gate itself. Leaving it set
// keeps `reason=processing` forever and the keyboard mic unclickable.
defer {
completeFinalizeCleanup(
sessionId: sessionId,
utteranceId: utteranceId
)
}
guard let conversationID = request.conversationID else {
claimAndStoreTerminal(utteranceId: utteranceId) {
storeFinalizedError(
AppL10n.string("flow.error.aiQuestionFailed"),
kind: .generic,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
}
return
}
// Do not publish the prompt as a "transcript" — clipboard skills
// send an XML envelope that must never appear above the mic.
let pipelineStore = AppGroupStore()
do {
let resolvedQuestion = try await resolvedQuestion(for: request)
let taskKind = if pipelineStore.credentialSource == .managed,
request.requestPurpose == nil {
ManagedGatewayQuestionRouter.taskKind(
for: resolvedQuestion,
requestedTaskKind: request.taskKind
)
} else {
request.taskKind
}
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations,
taskKind: taskKind,
requestSource: request.requestSource,
requestPurpose: request.requestPurpose,
oobeFeature: request.oobeFeature,
thinkingEnabled: request.thinkingEnabled,
analyticsClient: analyticsClient,
analyticsFeature: Self.analyticsFeature(for: request.taskKind)
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
question: resolvedQuestion,
conversationID: conversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
) { [weak self] partial in
Task { @MainActor in
self?.publishStreamingAIAnswerIfNeeded(
partial,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID,
force: partial.isEmpty
)
}
}
// Abort during the LLM await must not commit or deliver.
guard canStoreTerminal(for: utteranceId) else { return }
publishStreamingAIAnswerIfNeeded(
answer,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID,
force: true
)
await service.commitSuccessfulTurn(
question: resolvedQuestion,
answer: answer,
conversationID: conversationID
)
claimAndStoreTerminal(utteranceId: utteranceId) {
storeFinalizedResult(
answer,
warning: nil,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID
)
}
} catch {
claimAndStoreTerminal(utteranceId: utteranceId) {
storeFinalizedError(
Self.aiQuestionFailureMessage(for: error),
kind: .generic,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID
)
}
}
}
private func resolvedQuestion(
for request: PrefilledAIRequest
) async throws -> String {
guard let url = request.webPageURL else { return request.question }
let webpage = try await webPageContentService.fetchSummarySource(from: url)
return AIClipboardPrompt.compose(
instruction: request.question,
material: webpage.summaryMaterial
)
}
private func beginAudioPrime(_ command: FlowCommand) {
guard startingUtteranceId == nil,
!isUtteranceRecording,
!isUtteranceProcessing,
!hasUnacknowledgedTerminalResult() else {
return
}
bindSessionASRIfNeeded()
if let cloudASR = sessionASR as? CloudASRService {
// Same locale source as `beginUtterance`, so the prime is adoptable.
cloudASR.primeUtteranceStreaming(
locale: SpeechLocaleResolver.resolve(store.localeId)
)
}
startAudioPrime(id: command.utteranceId, origin: "micTouch")
}
private func startAudioPrime(id: UUID, origin: String) {
if audioPrimeTask != nil || capture.engineHasRecentAudio(maxAge: 2) {
audioPrimeID = id
audioPrimeCancellationRequested = false
return
}
audioPrimeID = id
audioPrimeCancellationRequested = false
let task = Task { @MainActor [weak self] in
guard let self else { return false }
return await self.startCaptureForPiPUtteranceIfNeeded()
}
audioPrimeTask = task
Task { @MainActor [weak self] in
let started = await task.value
guard let self else { return }
guard self.audioPrimeID == id else {
if self.audioPrimeCancellationRequested,
self.startingUtteranceId == nil,
!self.isUtteranceRecording,
!self.isUtteranceProcessing,
self.capture.running {
self.capture.stop(releaseSession: false)
_ = await self.pipController.reassertKeepAliveAudioSession()
}
self.audioPrimeTask = nil
self.audioPrimeCancellationRequested = false
self.refreshHostReady()
return
}
self.audioPrimeTask = nil
if self.audioPrimeCancellationRequested,
self.startingUtteranceId == nil,
!self.isUtteranceRecording,
!self.isUtteranceProcessing {
self.audioPrimeID = nil
self.audioPrimeCancellationRequested = false
if self.capture.running {
self.capture.stop(releaseSession: false)
_ = await self.pipController.reassertKeepAliveAudioSession()
}
self.refreshHostReady()
return
}
self.refreshHostReady()
FlowDiagnostics.log(
"audio prime completed started=\(started ? 1 : 0) "
+ "origin=\(origin) utterance=\(id.uuidString.prefix(8))"
)
}
}
private func cancelAudioPrime(_ command: FlowCommand) {
guard audioPrimeID == command.utteranceId,
startingUtteranceId == nil,
!isUtteranceRecording,
!isUtteranceProcessing else {
return
}
(sessionASR as? CloudASRService)?.cancelPrimedUtteranceStreaming()
audioPrimeCancellationRequested = true
// `capture.start()` is not cancellation-safe. Never stop it mid-start;
// the completion waiter performs cleanup unless a real utterance adopts
// this same single-flight task first.
guard audioPrimeTask == nil else { return }
audioPrimeID = nil
audioPrimeCancellationRequested = false
capture.stop(releaseSession: false)
Task { @MainActor [weak self] in
guard let self else { return }
_ = await self.pipController.reassertKeepAliveAudioSession()
self.refreshHostReady()
}
}
private func storeCurrentPartial(
_ text: String,
generation: UInt64,
utteranceId: UUID?
) {
guard utteranceGeneration == generation,
currentUtteranceId == utteranceId,
let activeSessionId,
let currentUtteranceId,
!terminalUtteranceIds.contains(currentUtteranceId) else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: currentCommandSeq,
status: .partial,
text: trimmed,
rawText: trimmed,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
utteranceMode: currentUtteranceMode,
aiConversationID: pendingAIConversationID
)
)
}
private func storeCurrentError(
_ message: String,
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
status: FlowResult.Status = .error
) {
finishUtteranceAnalytics(
utteranceID: currentUtteranceId,
success: false,
failureCategory: Self.transcriptionFailureCategory(
kind: kind,
status: status
)
)
guard let activeSessionId, let currentUtteranceId else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: currentCommandSeq,
status: status,
text: message,
errorKind: kind,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
utteranceMode: currentUtteranceMode,
aiConversationID: pendingAIConversationID
)
)
}
private func handleStartRecordingCommand(
utteranceId: UUID?,
commandSeq: Int64,
startToken: FlowUtteranceStartToken,
deadlineAt: TimeInterval
) async {
guard !Task.isCancelled,
canContinueStart(startToken),
Date().timeIntervalSince1970 < deadlineAt else {
failStartIfCurrent(startToken)
return
}
refreshHostReady()
// Keep the utterance gate closed until the route is stable and the
// tap has produced a real frame. The rolling three-second preroll
// preserves speech spoken during this short readiness window.
guard await ensureCaptureStartedForUtterance() else {
guard !Task.isCancelled, canContinueStart(startToken) else { return }
failUtterance(
message: AppL10n.string("flow.coldStart.error.audioTimeout"),
kind: .audioUnavailable
)
return
}
guard !Task.isCancelled, canContinueStart(startToken) else {
releaseOrphanedCaptureIfNeeded()
return
}
guard Date().timeIntervalSince1970 < deadlineAt else {
failStartIfCurrent(startToken)
return
}
let micReady: Bool
if capture.engineHasRecentAudio(maxAge: 2) {
micReady = true
} else {
let firstBudget = min(
4,
max(0, deadlineAt - Date().timeIntervalSince1970)
)
var flowing = await capture.awaitAudioFlowing(
timeout: firstBudget
)
let remaining = deadlineAt - Date().timeIntervalSince1970
if !flowing, remaining > 1 {
// First cold capture after PiP arm often proves audio late — one rebuild.
debug("PiP audio proof timeout — one capture rebuild before failing")
capture.stop(releaseSession: false)
_ = await startCaptureForPiPUtteranceIfNeeded()
flowing = await capture.awaitAudioFlowing(
timeout: min(2, max(0, deadlineAt - Date().timeIntervalSince1970))
)
}
micReady = flowing && Date().timeIntervalSince1970 < deadlineAt
}
guard !Task.isCancelled, canContinueStart(startToken) else {
releaseOrphanedCaptureIfNeeded()
return
}
if !micReady {
failUtterance(
message: AppL10n.string("flow.coldStart.error.audioTimeout"),
kind: .audioUnavailable
)
return
}
beginUtterance(
utteranceId: utteranceId,
commandSeq: commandSeq,
requireRecentAudio: false
)
if pendingStopUtteranceId == currentUtteranceId {
pendingStopUtteranceId = nil
endUtterance()
}
}
private func failStartIfCurrent(_ token: FlowUtteranceStartToken) {
guard canContinueStart(token) else { return }
failUtterance(
message: AppL10n.string("flow.coldStart.error.audioTimeout"),
kind: .audioUnavailable
)
}
private func ensureCaptureStartedForUtterance() async -> Bool {
audioPrimeCancellationRequested = false
audioPrimeID = nil
if let task = audioPrimeTask {
let started = await task.value
audioPrimeTask = nil
if started || capture.engineHasRecentAudio(maxAge: 2) {
return true
}
}
return await startCaptureForPiPUtteranceIfNeeded()
}
/// Start capture for a PiP utterance without blocking on the first frame.
/// Cold first start after relaunch often needs one rebuild (VPIO / -66635).
private func startCaptureForPiPUtteranceIfNeeded() async -> Bool {
if capture.engineHasRecentAudio(maxAge: 2) {
return true
}
do {
try await capture.start()
if capture.engineIsLive || capture.engineHasRecentAudio(maxAge: 2) {
return true
}
debug("PiP utterance capture soft-dead after start — one rebuild retry")
capture.stop(releaseSession: false)
try? await Task.sleep(nanoseconds: 120_000_000)
try await capture.start()
// Do not accept `running && !engineLive` — that is the silent-mic failure mode.
return capture.engineIsLive || capture.engineHasRecentAudio(maxAge: 2)
} catch {
debug("PiP utterance capture start failed: \(error.localizedDescription) — retry once")
capture.stop(releaseSession: false)
try? await Task.sleep(nanoseconds: 150_000_000)
do {
try await capture.start()
return capture.engineIsLive || capture.engineHasRecentAudio(maxAge: 2)
} catch {
debug("PiP utterance capture retry failed: \(error.localizedDescription)")
return false
}
}
}
private func releaseCaptureAfterPiPUtteranceIfNeeded() {
guard capture.running else { return }
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
capture.stop(releaseSession: false)
Task { @MainActor [weak self] in
guard let self else { return }
_ = await self.pipController.reassertKeepAliveAudioSession()
self.refreshHostReady()
}
pipController.updateWaveformLevels([])
}
private func releaseOrphanedCaptureIfNeeded() {
guard currentUtteranceId == nil,
!isUtteranceRecording,
!isUtteranceProcessing else { return }
traceState("capture.start.discarded", extra: "reason=staleUtterance")
releaseCaptureAfterPiPUtteranceIfNeeded()
}
private func canContinueStart(_ token: FlowUtteranceStartToken) -> Bool {
FlowUtteranceLifecyclePolicy.canContinueStart(
token: token,
currentGeneration: utteranceGeneration,
currentUtteranceId: currentUtteranceId,
terminalUtteranceIds: terminalUtteranceIds
)
}
private func beginUtterance(
utteranceId: UUID? = nil,
commandSeq: Int64 = 0,
requireRecentAudio: Bool = true
) {
if let utteranceId, terminalUtteranceIds.contains(utteranceId) {
traceState(
"beginUtterance.ignored",
extra: "reason=terminalId utterance=\(utteranceId.uuidString.prefix(8))"
)
return
}
if requireRecentAudio {
guard capture.engineHasRecentAudio(maxAge: 2) else {
traceState("beginUtterance.blocked", extra: "reason=audioNotRecent")
failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
)
return
}
} else {
// PiP cold path: capture was just started; open the gate so the
// first tap frames enter the ASR stream instead of preroll only.
guard capture.running || capture.engineIsLive else {
traceState("beginUtterance.blocked", extra: "reason=captureNotRunning")
failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
)
return
}
}
guard !isUtteranceProcessing else {
traceState("beginUtterance.ignored", extra: "reason=processing")
debug("beginUtterance ignored — previous utterance still processing")
return
}
bindSessionASRIfNeeded()
let expectedEngine = store.engineMode
if sessionASREngineMode != expectedEngine {
traceState(
"beginUtterance.rebindMismatch",
extra: "expectedEngine=\(expectedEngine) boundEngine=\(sessionASREngineMode ?? "nil")"
)
bindSessionASRIfNeeded(force: true)
}
// Usually already warm from session start; refresh without blocking the mic gate.
scheduleASRWarmup()
if currentUtteranceId != utteranceId || currentUtteranceId == nil {
guard prepareUtteranceIdentity(
utteranceId: utteranceId,
commandSeq: commandSeq
) else { return }
}
let generation = utteranceGeneration
let taskUtteranceId = currentUtteranceId
currentCommandSeq = commandSeq
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
bestPartialSnapshot = ""
utterancePCMSamples = []
chunkWarnings = []
asrCompletedGeneration = nil
asrFailureMessage = nil
let localeId = store.localeId
FlowSessionBridge.setTranscriptionLanguage(localeId)
FlowSessionBridge.clearPendingTranscription()
let locale = SpeechLocaleResolver.resolve(localeId)
let stream = capture.beginUtterance()
let cloudService = asr as? CloudASRService
let useStreaming =
store.engineMode == "cloud"
&& (
cloudService?.supportsUtteranceStreaming
?? CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
)
let pipeline: ChunkedUtterancePipeline?
if useStreaming {
chunkedPipeline = nil
pipeline = nil
} else {
let created = ChunkedUtterancePipeline(asr: asr, locale: locale)
chunkedPipeline = created
pipeline = created
}
isUtteranceRecording = true
startingUtteranceId = nil
startTransactionDeadlineAt = nil
if let activeSessionId, let currentUtteranceId {
FlowSessionBridge.writeStartTransaction(
FlowStartTransaction(
sessionID: activeSessionId,
utteranceID: currentUtteranceId,
deadlineAt: Date().timeIntervalSince1970,
phase: .recording
)
)
}
utteranceRecordingStartedAt = Date()
startUtteranceSafetyTimer()
refreshHostReady()
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
"asrType=\(type(of: asr)) streaming=\(useStreaming) " +
"localCustomLM=\(store.localASRCustomLanguageModelEnabled) " +
"max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
)
let cloudASRForStreaming = useStreaming ? cloudService : nil
if let taskUtteranceId {
// A healed delivery gate may allow a replacement utterance before
// an old finalize task unwinds. Close any old value task first.
analyticsOperations.cancelAll()
analyticsOperations.start(
utteranceID: taskUtteranceId,
feature: FlowAnalyticsFeatureMapping.recordingFeature(
for: currentUtteranceMode
),
executionMode: analyticsExecutionMode,
client: analyticsClient
)
}
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
let outcome: ChunkedUtterancePipelineOutcome
if let cloud = cloudASRForStreaming {
outcome = await cloud.transcribeUtteranceStreaming(stream: stream, locale: locale) { partial in
Task { @MainActor in
guard let manager else { return }
guard manager.utteranceGeneration == generation,
manager.currentUtteranceId == taskUtteranceId else { return }
manager.currentPartial = partial
manager.storeCurrentPartial(
partial,
generation: generation,
utteranceId: taskUtteranceId
)
}
}
} else if let pipeline {
outcome = await pipeline.transcribe(stream: stream) { partial in
Task { @MainActor in
guard let manager else { return }
guard manager.utteranceGeneration == generation,
manager.currentUtteranceId == taskUtteranceId else { return }
manager.currentPartial = partial
manager.storeCurrentPartial(
partial,
generation: generation,
utteranceId: taskUtteranceId
)
}
}
} else {
outcome = .failure(SharedL10n.string("error.asr.noSpeech"))
}
// Re-bind `manager` inside the `@MainActor` block so the
// weak reference is captured under the right isolation. Swift
// 6 strict concurrency otherwise complains about a
// task-isolated reference escaping into a main-actor closure.
await MainActor.run { [weak manager] in
guard let manager else { return }
guard manager.utteranceGeneration == generation,
manager.currentUtteranceId == taskUtteranceId else {
return
}
manager.asrCompletedGeneration = generation
FlowDiagnostics.log(
"asr finished streaming=\(useStreaming) partialLen=\(manager.currentPartial.count) " +
"finalPending=\(manager.lastFinal.isEmpty)"
)
switch outcome {
case .success(let success):
FlowTrace.transcript(
"asr.outcome",
success.text,
"engine=\(manager.store.engineMode) streaming=\(useStreaming ? 1 : 0) "
+ "warnings=\(success.chunkWarnings.count)"
)
manager.lastFinal = success.text
manager.lastFinalWithPauseMarks = success.textWithPauseMarks
manager.chunkWarnings = success.chunkWarnings
manager.currentPartial = ""
case .failure(let message):
manager.asrFailureMessage = message
manager.debug(
"asr error category=asrFailure errorBytes=\(message.utf8.count)"
)
FlowTrace.warn(
"asr.outcome.failed",
"engine=\(manager.store.engineMode) streaming=\(useStreaming ? 1 : 0) "
+ "partialLen=\(manager.currentPartial.count) "
+ "bestPartialLen=\(manager.bestPartialSnapshot.count) "
+ "errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
// Prefer any non-empty partial over a hard no-speech failure.
// Clearing `bestPartialSnapshot` here would race finalize
// into an empty transcript even when ASR had usable text.
let recovery = [
manager.currentPartial,
manager.bestPartialSnapshot
]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first(where: { !$0.isEmpty })
// Without a partial, keep recording: capture is independent
// of ASR, and finalize retries the full PCM through batch
// ASR (`recognitionFailed`) before reporting the error.
if let recovery {
manager.lastFinal = recovery
manager.debug("asr error recovered via partial len=\(recovery.count)")
} else if manager.isUtteranceRecording {
manager.debug("asr error while recording — deferring to batch fallback")
}
case .cancelled:
// A provider-side cancellation while the mic is still open
// has no later successful finalize path. Close the
// utterance now so its analytics STARTED cannot be orphaned.
let hasRecoverableText = !manager.currentPartial
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !manager.bestPartialSnapshot
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if manager.isUtteranceRecording, !hasRecoverableText {
manager.failUtterance(
message: AppL10n.string(
"flow.error.recognitionInterrupted"
),
kind: .recognitionInterrupted
)
}
}
}
}
debug("utterance recording started")
}
@discardableResult
private func prepareUtteranceIdentity(
utteranceId: UUID?,
commandSeq: Int64
) -> Bool {
let resolved = utteranceId ?? UUID()
guard !terminalUtteranceIds.contains(resolved) else {
traceState(
"utteranceIdentity.rejected",
extra: "reason=terminalId utterance=\(resolved.uuidString.prefix(8))"
)
return false
}
guard currentUtteranceId != resolved else {
currentCommandSeq = commandSeq
return true
}
utteranceGeneration &+= 1
currentUtteranceId = resolved
currentCommandSeq = commandSeq
return true
}
private func startUtteranceSafetyTimer() {
utteranceSafetyTask?.cancel()
let utteranceId = currentUtteranceId
utteranceSafetyTask = Task { @MainActor [weak self] in
let timeout = FlowSessionKeys.maxUtteranceDuration + 10
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
guard self.isUtteranceRecording, self.currentUtteranceId == utteranceId else { return }
self.storeCurrentError(
AppL10n.string("flow.error.recognitionInterrupted"),
kind: .recognitionInterrupted,
status: .timeout
)
self.abortUtterance()
self.debug("utterance safety timer aborted stale recording")
}
}
private func endUtterance() {
guard isUtteranceRecording else { return }
let duration = utteranceRecordingStartedAt.map { Date().timeIntervalSince($0) } ?? 0
if currentUtteranceMode != .editLastInput,
duration < FlowEmptyTapSkipPolicy.maxDurationSeconds {
let samples = capture.utterancePCMSnapshot()
let peak = FlowEmptyTapSkipPolicy.peakAbs(samples)
if FlowEmptyTapSkipPolicy.shouldSkip(
durationSeconds: duration,
sampleCount: samples.count,
peakAmplitude: peak
) {
FlowTrace.pipeline(
"utterance.skipEmptyTap",
"duration=\(String(format: "%.3f", duration))s "
+ "samples=\(samples.count) peak=\(String(format: "%.4f", peak))"
)
abortUtterance(kind: .discardedEmpty, message: "")
return
}
}
// Close the mic gate first, then mark processing before dropping the
// recording flag so the poll loop cannot start a second utterance.
isUtteranceRecording = false
isUtteranceProcessing = true
FlowSessionBridge.clearStartTransaction()
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
refreshHostReady()
// Snapshot pipelined partial before drain — fallback if the final chunk ASR drops tail text.
bestPartialSnapshot = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
// Do NOT cancel `asrTask` or `asr` — drain trailing PCM, then finalize.
// Capture ids now: a cancelled finalize must still clear *this*
// utterance's processing gate even if currentUtteranceId was cleared
// by a racing fail/abort path.
let drainingSessionId = activeSessionId
let drainingUtteranceId = currentUtteranceId
let drainingCommandSeq = currentCommandSeq
let drainingGeneration = utteranceGeneration
finalizeTask?.cancel()
finalizeTask = Task { @MainActor [weak self] in
guard let self else { return }
let drainReport = await self.capture.endUtteranceAndDrain()
FlowDiagnostics.logDrain(drainReport)
self.utterancePCMSamples = self.capture.consumeUtteranceSamples()
FlowTrace.pipeline(
"utterance.pcmCollected",
"samples=\(self.utterancePCMSamples.count) "
+ "seconds=\(FlowTrace.seconds(samples: self.utterancePCMSamples.count)) "
+ "rms=\(FlowTrace.rms(self.utterancePCMSamples)) "
+ "capture[\(self.capture.frameReport().summary)]"
)
self.capture.stop(releaseSession: false)
_ = await self.pipController.reassertKeepAliveAudioSession()
self.pipController.updateWaveformLevels([])
await self.finalizeUtterance(
sessionId: drainingSessionId,
utteranceId: drainingUtteranceId,
commandSeq: drainingCommandSeq,
generation: drainingGeneration
)
}
debug("utterance stopped, draining tail")
}
private func abortUtterance(
kind: FlowSessionKeys.TranscriptionErrorKind = .recognitionInterrupted,
message: String? = nil
) {
let utteranceId = currentUtteranceId
if claimTerminal(utteranceId: utteranceId) {
storeCurrentError(
message ?? AppL10n.string("flow.error.recognitionInterrupted"),
kind: kind,
status: .aborted
)
}
analyticsOperations.cancel(utteranceID: utteranceId)
isUtteranceRecording = false
isUtteranceProcessing = false
startingUtteranceId = nil
startTransactionDeadlineAt = nil
FlowSessionBridge.clearStartTransaction()
utteranceRecordingStartedAt = nil
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
Task { await chunkedPipeline?.cancel() }
chunkedPipeline = nil
asr.cancel()
capture.cancelUtterance()
releaseCaptureAfterPiPUtteranceIfNeeded()
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
bestPartialSnapshot = ""
utterancePCMSamples = []
chunkWarnings = []
pendingFieldContext = nil
clearPendingInstructionState()
utteranceGeneration &+= 1
currentUtteranceId = nil
currentCommandSeq = 0
refreshHostReady()
debug("utterance aborted")
}
private func failUtterance(
message: String,
kind: FlowSessionKeys.TranscriptionErrorKind = .asrFailed
) {
guard claimTerminal(utteranceId: currentUtteranceId) else { return }
isUtteranceRecording = false
isUtteranceProcessing = false
startingUtteranceId = nil
startTransactionDeadlineAt = nil
FlowSessionBridge.clearStartTransaction()
utteranceRecordingStartedAt = nil
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
Task { await chunkedPipeline?.cancel() }
chunkedPipeline = nil
asr.cancel()
capture.cancelUtterance()
releaseCaptureAfterPiPUtteranceIfNeeded()
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
bestPartialSnapshot = ""
utterancePCMSamples = []
chunkWarnings = []
storeCurrentError(message, kind: kind)
pendingFieldContext = nil
clearPendingInstructionState()
utteranceGeneration &+= 1
currentUtteranceId = nil
currentCommandSeq = 0
refreshHostReady()
debug("utterance failed: \(message)")
}
private func claimTerminal(utteranceId: UUID?) -> Bool {
guard let utteranceId, !terminalUtteranceIds.contains(utteranceId) else {
return false
}
terminalUtteranceIds.insert(utteranceId)
return true
}
/// After an `await`, refuse to write if this utterance was aborted or replaced.
private func canStoreTerminal(for utteranceId: UUID?) -> Bool {
guard let utteranceId else { return false }
return FlowTerminalStorePolicy.canStore(
currentUtteranceId: currentUtteranceId,
finishedUtteranceId: utteranceId,
alreadyTerminal: terminalUtteranceIds.contains(utteranceId)
)
}
/// Claim + write with no `await` between them, so abort cannot sneak a
/// second payload in after `claimTerminal`.
private func claimAndStoreTerminal(utteranceId: UUID?, store: () -> Void) {
guard canStoreTerminal(for: utteranceId) else { return }
guard claimTerminal(utteranceId: utteranceId) else { return }
store()
}
private func clearPendingInstructionState() {
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
pendingAIConversationID = nil
pendingManagedRequestPurpose = nil
pendingManagedOOBEFeature = nil
pendingProcessingDeadlineAt = nil
currentUtteranceMode = .dictation
}
private func finalizeUtterance(
sessionId finalizeSessionId: UUID?,
utteranceId finalizeUtteranceId: UUID?,
commandSeq finalizeCommandSeq: Int64,
generation finalizeGeneration: UInt64
) async {
let pipelineStarted = Date()
let fieldContext = pendingFieldContext
let utteranceMode = currentUtteranceMode
let editSourceText = pendingEditSourceText
let sourceHistoryEntryID = pendingSourceHistoryEntryID
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
let aiConversationID = pendingAIConversationID
let managedRequestPurpose = pendingManagedRequestPurpose
let managedOOBEFeature = pendingManagedOOBEFeature
let processingDeadlineAt = pendingProcessingDeadlineAt
let asrEngineMode = sessionASREngineMode ?? store.engineMode
// ALWAYS clear the processing gate for this utterance. The previous
// guard required currentUtteranceId to still match; a racing
// fail/abort/cancel path could nil the id (or leave processing stuck)
// and then skip refreshHostReady — keyboard stayed white forever
// while host logs still said "utterance finalized".
defer {
// Any path that returns without an explicit terminal event is a
// cancellation from the user's perspective. The registry is keyed
// by utterance ID, so this cannot finish a newer recording.
analyticsOperations.cancel(utteranceID: finalizeUtteranceId)
pendingFieldContext = nil
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
pendingAIConversationID = nil
pendingManagedRequestPurpose = nil
pendingManagedOOBEFeature = nil
pendingProcessingDeadlineAt = nil
if currentUtteranceMode == utteranceMode {
currentUtteranceMode = .dictation
}
completeFinalizeCleanup(
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId
)
}
let asrWait = asrWaitTimeout()
FlowDiagnostics.log(
"finalize start asrWait=\(Int(asrWait))s engine=\(store.engineMode)"
)
let normalASRDeadline = Date().addingTimeInterval(asrWait)
let asrDeadline = processingDeadlineAt.map {
min(normalASRDeadline, Date(timeIntervalSince1970: $0))
} ?? normalASRDeadline
while Date() < asrDeadline {
if !lastFinal.isEmpty { break }
if asrCompletedGeneration == finalizeGeneration { break }
if asrTask?.isCancelled == true { break }
// Honour cooperative cancel so a replaced finalize exits promptly,
// but still run defer cleanup (unlike an early `return` mid-polish
// that used to leave processing=true when ids no longer matched).
if Task.isCancelled { break }
try? await Task.sleep(nanoseconds: 100_000_000)
}
// The recognizer never reported back within the wait: treat it as a
// failure so batch ASR can still recover the captured audio.
let asrStalled = lastFinal.isEmpty
&& asrCompletedGeneration != finalizeGeneration
&& !Task.isCancelled
if lastFinal.isEmpty {
FlowDiagnostics.log(
"ASR wait elapsed — cancelling ASR task and using best available transcript "
+ "stalled=\(asrStalled ? 1 : 0)"
)
asrTask?.cancel()
Task { await chunkedPipeline?.cancel() }
}
let asrElapsed = Date().timeIntervalSince(pipelineStarted)
FlowDiagnostics.log("ASR phase done in \(String(format: "%.1f", asrElapsed))s finalLen=\(lastFinal.count)")
if utteranceMode == .aiQuestion {
FlowTrace.pipeline(
"aiQuestion.asrReady",
"finalLen=\(lastFinal.count) partialLen=\(bestPartialSnapshot.count)"
)
} else {
FlowTrace.transcript(
"asr.beforeGuard",
lastFinal,
"stage=stitchedFinal engine=\(store.engineMode) "
+ "elapsed=\(String(format: "%.2f", asrElapsed))s"
)
FlowTrace.transcript(
"asr.bestPartial",
bestPartialSnapshot,
"stage=partialSnapshot"
)
}
var text = UtteranceTranscriptGuard.resolve(
stitchedFinal: lastFinal,
partialSnapshot: bestPartialSnapshot
)
if text.isEmpty {
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
}
let wantsBatchFallback = UtteranceBatchFallbackPolicy.shouldRunBatchFallback(
stitchedFinal: lastFinal,
partialSnapshot: bestPartialSnapshot,
recognitionFailed: asrFailureMessage != nil,
recognitionStalled: asrStalled
)
FlowTrace.pipeline(
"batchFallback.decision",
"wanted=\(wantsBatchFallback ? 1 : 0) stalled=\(asrStalled ? 1 : 0) "
+ "pcmSamples=\(utterancePCMSamples.count) "
+ "pcmRms=\(FlowTrace.rms(utterancePCMSamples)) "
+ "stitchedLen=\(lastFinal.count) partialLen=\(bestPartialSnapshot.count) "
+ "resolvedLen=\(text.count)"
)
let hasBatchFallbackBudget = processingDeadlineAt.map {
Date().timeIntervalSince1970 + FlowSessionKeys.batchASRFallbackTimeout < $0
} ?? true
if wantsBatchFallback,
hasBatchFallbackBudget,
!utterancePCMSamples.isEmpty {
text = await runBatchASRFallback(currentText: text)
}
let rawTextForPolish = text == lastFinal && !lastFinalWithPauseMarks.isEmpty
? lastFinalWithPauseMarks
: text
utterancePCMSamples = []
guard !text.isEmpty else {
let wasCancelled = (asrTask?.isCancelled == true || Task.isCancelled)
let key = wasCancelled
? "flow.error.recognitionInterrupted"
: "flow.error.noSpeech"
let kind: FlowSessionKeys.TranscriptionErrorKind =
asrFailureMessage == nil
? (wasCancelled ? .recognitionInterrupted : .noSpeech)
: .asrFailed
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
FlowTrace.warn(
"finalize.emptyTranscript",
"engine=\(store.engineMode) elapsed=\(String(format: "%.2f", asrElapsed))s "
+ "kind=\(kind.rawValue) asrCancelled=\(asrTask?.isCancelled == true ? 1 : 0) "
+ "capture[\(capture.frameReport().summary)]"
)
utteranceRecordingStartedAt = nil
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
asrFailureMessage ?? AppL10n.string(key),
kind: kind,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
return
}
if utteranceMode != .aiQuestion {
finishUtteranceAnalytics(
utteranceID: finalizeUtteranceId,
success: true
)
}
// Re-read App Group at finalize so dictionary and translation changes
// from the keyboard extension are visible before local correction,
// polish, or translation.
let pipelineStore = AppGroupStore()
let postASR = FlowASRPostProcessor.process(
text: text,
textForPolish: rawTextForPolish,
engineMode: asrEngineMode,
dictionary: pipelineStore.personalDictionary
)
text = postASR.text
let textForPolish = postASR.textForPolish
storeRawCandidate(
text,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
let recordingDuration = consumeRecordingDuration()
if utteranceMode == .editLastInput {
EditUsageMetricsStore.recordInstructionDuration(recordingDuration)
}
let engineMode = asrEngineMode
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
if utteranceMode == .aiQuestion {
guard let aiConversationID else {
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.aiQuestionFailed"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
return
}
let spoken = AIClipboardPrompt.resolveSpoken(
question: text,
material: clipboardMaterialForAIQuestion(store: pipelineStore)
)
guard case .ready(let question) = spoken else {
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.clipboardUnavailable"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
aiConversationID: aiConversationID
)
return
}
do {
let taskKind = if pipelineStore.credentialSource == .managed,
managedRequestPurpose == nil {
// Classify the post-ASR spoken text before clipboard
// resolution can wrap it in an internal prompt envelope.
ManagedGatewayQuestionRouter.taskKind(for: text)
} else {
ManagedGatewayTaskKind.aiQuestion
}
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations,
taskKind: taskKind,
requestPurpose: managedRequestPurpose,
oobeFeature: managedOOBEFeature,
analyticsClient: analyticsClient,
analyticsFeature: .aiAssistant
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let publishUtteranceId = finalizeUtteranceId
let publishSessionId = finalizeSessionId
let publishCommandSeq = finalizeCommandSeq
let publishConversationID = aiConversationID
let answer = try await service.answer(
question: question,
conversationID: aiConversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId,
analyticsOperation: analyticsOperations.operation(
for: finalizeUtteranceId
)
) { [weak self] partial in
Task { @MainActor in
self?.publishStreamingAIAnswerIfNeeded(
partial,
sessionId: publishSessionId,
utteranceId: publishUtteranceId,
commandSeq: publishCommandSeq,
aiConversationID: publishConversationID,
force: partial.isEmpty
)
}
}
// Abort during the LLM await must not commit or deliver.
guard canStoreTerminal(for: finalizeUtteranceId) else { return }
// Flush the final draft when throttle skipped the last characters.
publishStreamingAIAnswerIfNeeded(
answer,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
aiConversationID: aiConversationID,
force: true
)
await service.commitSuccessfulTurn(
question: question,
answer: answer,
conversationID: aiConversationID
)
claimAndStoreTerminal(utteranceId: finalizeUtteranceId) {
storeFinalizedResult(
answer,
warning: chunkNote,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
aiConversationID: aiConversationID
)
}
} catch {
claimAndStoreTerminal(utteranceId: finalizeUtteranceId) {
storeFinalizedError(
Self.aiQuestionFailureMessage(for: error),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
aiConversationID: aiConversationID
)
}
}
return
}
let polishContext = PolishContext(
appContext: pipelineStore.detectedAppContext?.context ?? .unknown,
precedingText: fieldContext?.precedingText,
followingText: fieldContext?.followingText,
fieldHints: fieldContext.map(FieldHints.init(from:)),
maxPrecedingChars: 600,
maxFollowingChars: 200
)
var delivered = text
let polishStarted = Date()
let isEditLastInput = utteranceMode == .editLastInput
let isInstructionMode = isEditLastInput
let polishMode: PolishingService.PolishMode = isInstructionMode
? .polish
: pipelineStore.polishModeForPipeline
let instructionPrompt: (system: String, user: String)? = {
if isEditLastInput,
let source = editSourceText,
!source.isEmpty {
let input = EditLastInputPromptComposer.Input(
sourceText: source,
spokenInstruction: textForPolish
)
return (
EditLastInputPromptComposer.systemPrompt(),
EditLastInputPromptComposer.userMessage(input)
)
}
return nil
}()
if isInstructionMode, instructionPrompt == nil {
FlowDiagnostics.log("instruction edit missing source — failing closed")
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.editLastInputFailed"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
return
}
let modeLabel = isEditLastInput
? "editLastInput"
: Self.polishModeLogLabel(polishMode)
FlowDiagnostics.log(
"finalize LLM mode=\(modeLabel) translationTarget=\(pipelineStore.translationTargetLocaleId)"
)
FlowTrace.transcript(
"polish.input",
textForPolish,
"mode=\(modeLabel) engine=\(engineMode) "
+ "provider=\(pipelineStore.polishProviderIdOverride ?? "default") "
+ "recordedSeconds=\(String(format: "%.2f", recordingDuration))"
)
do {
// If the finalize task was cancelled (cold-start churn / abort),
// skip the LLM round-trip and deliver the raw transcript so the
// keyboard is not left waiting on a result that never arrives.
// Edit mode must never insert the instruction ASR.
if Task.isCancelled {
throw CancellationError()
}
let outcome = try await Self.polishWithHostTimeout(
polisher: polisher,
text: instructionPrompt?.user ?? textForPolish,
mode: polishMode,
systemPrompt: instructionPrompt?.system,
providerIdOverride: pipelineStore.polishProviderIdOverride,
taskKind: isEditLastInput ? .editLastInput : nil,
requestPurpose: managedRequestPurpose,
oobeFeature: managedOOBEFeature,
context: isInstructionMode ? nil : polishContext,
timeoutLimit: processingDeadlineAt.map {
max(0.1, $0 - Date().timeIntervalSince1970)
}
)
let polished: String
if isEditLastInput, let source = editSourceText {
switch EditOutputValidator.validate(sourceText: source, output: outcome.text) {
case .success(let validated):
polished = validated
case .failure(let validationError):
throw validationError
}
} else {
polished = outcome.text
}
delivered = polished
FlowTrace.transcript(
"polish.output",
polished,
"mode=\(modeLabel) inputLen=\(text.count) "
+ "changed=\(polished == text ? 0 : 1) "
+ "elapsed=\(FlowTrace.seconds(since: polishStarted))s"
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
let historyEntry = isEditLastInput
? nil
: SpeechHistoryStore.shared.recordUtterance(
text: delivered,
prePolishText: text,
polishStyleID: outcome.polishStyleID
?? pipelineStore.activePolishStyleId,
polishStylePrompt: outcome.polishStylePrompt,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: isInstructionMode
? false
: pipelineStore.isTranslationEffective
)
storeFinalizedResult(
polished,
rawText: isInstructionMode ? nil : text,
warning: Self.combinedWarning(
chunkNote,
outcome.qualityDegraded
? AppL10n.string("flow.warning.polishDegradedQuality")
: nil
),
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
historyEntryID: isEditLastInput
? sourceHistoryEntryID
: historyEntry?.id,
historyEntryRevision: isEditLastInput
? sourceHistoryEntryRevision
: historyEntry?.revision
)
FlowDiagnostics.log(
"polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " +
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
)
} catch {
if isInstructionMode {
FlowDiagnostics.log(
"instruction edit failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.warn(
"editLastInput.failed",
"elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "\(Self.safeErrorLogMetadata(error))"
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.editLastInputFailed"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
} else {
// CancellationError is common when the user jumps back via
// startflow mid-polish; still deliver raw text. Other errors
// keep the existing polish-warning fallback.
let fallback = Self.makeFallbackDelivery(
rawText: text,
error: error,
engineMode: engineMode,
chunkWarning: chunkNote
)
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.warn(
"polish.failed",
"mode=\(Self.polishModeLogLabel(polishMode)) engine=\(engineMode) "
+ "elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.transcript(
"polish.fallback",
fallback.text,
"reason=polishFailed rawLen=\(text.count)"
)
delivered = fallback.text
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
let historyEntry = SpeechHistoryStore.shared.recordUtterance(
text: delivered,
prePolishText: text,
polishStyleID: pipelineStore.activePolishStyleId,
// The provider failed, so this fallback was not produced by
// the style Prompt and must not become Prompt-learning evidence.
polishStylePrompt: nil,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: pipelineStore.isTranslationEffective
)
storeFinalizedResult(
fallback.text,
rawText: text,
warning: fallback.polishWarning,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
historyEntryID: historyEntry?.id,
historyEntryRevision: historyEntry?.revision
)
}
}
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
bestPartialSnapshot = ""
utterancePCMSamples = []
chunkWarnings = []
chunkedPipeline = nil
debug("utterance finalized length=\(text.count)")
}
/// Drop the processing gate and republish hostReady after finalize.
/// Must not depend on a perfect id match — a racing fail/abort/cancel
/// used to skip this block and leave the keyboard stuck on white「识别中」
/// even after "utterance finalized" was logged.
private func completeFinalizeCleanup(sessionId: UUID?, utteranceId: UUID?) {
// A newer utterance may have started; never clobber its gate.
if let current = currentUtteranceId,
let finished = utteranceId,
current != finished {
debug(
"finalize cleanup skipped — newer utterance live " +
"finished=\(finished.uuidString.prefix(8)) current=\(current.uuidString.prefix(8))"
)
return
}
let wasProcessing = isUtteranceProcessing
isUtteranceProcessing = false
if currentUtteranceId == utteranceId || currentUtteranceId == nil {
currentUtteranceId = nil
currentCommandSeq = 0
}
refreshHostReady()
debug(
"finalize cleanup done wasProcessing=\(wasProcessing ? 1 : 0) " +
"utterance=\(utteranceId?.uuidString.prefix(8) ?? "nil") " +
"session=\(sessionId?.uuidString.prefix(8) ?? "nil")"
)
}
private func publishStreamingAIAnswerIfNeeded(
_ text: String,
sessionId: UUID?,
utteranceId: UUID?,
commandSeq: Int64,
aiConversationID: UUID?,
force: Bool
) {
guard aiAnswerStreamThrottle.shouldPublish(
accumulatedCount: text.count,
force: force
) else { return }
storeStreamingAIAnswer(
text,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: aiConversationID
)
}
private func storeStreamingAIAnswer(
_ text: String,
sessionId: UUID?,
utteranceId: UUID?,
commandSeq: Int64,
aiConversationID: UUID?
) {
guard let sessionId, let utteranceId else { return }
guard currentUtteranceId == utteranceId,
!terminalUtteranceIds.contains(utteranceId) else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
status: .streaming,
text: text,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
utteranceMode: .aiQuestion,
aiConversationID: aiConversationID
)
)
}
private func storeFinalizedResult(
_ text: String,
rawText: String? = nil,
warning: String?,
sessionId: UUID?,
utteranceId: UUID?,
commandSeq: Int64,
historyEntryID: UUID? = nil,
historyEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil
) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
storeFinalizedError(
AppL10n.string("flow.error.noSpeech"),
kind: .noSpeech,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
return
}
guard let sessionId, let utteranceId else { return }
if currentUtteranceMode == .aiQuestion {
FlowTrace.pipeline(
"aiQuestion.delivered",
"answerLen=\(trimmed.count) "
+ "utterance=\(utteranceId.uuidString.prefix(8)) "
+ "commandSeq=\(commandSeq)"
)
} else {
FlowTrace.transcript(
"host.delivered",
trimmed,
"utterance=\(utteranceId.uuidString.prefix(8)) commandSeq=\(commandSeq) "
+ "warning=\(warning == nil ? 0 : 1)"
)
}
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
status: .final,
text: trimmed,
warning: warning,
rawText: rawText,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
utteranceMode: currentUtteranceMode,
historyEntryID: historyEntryID,
historyEntryRevision: historyEntryRevision,
aiConversationID: aiConversationID
)
)
}
private func storeRawCandidate(
_ text: String,
sessionId: UUID?,
utteranceId: UUID?,
commandSeq: Int64
) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let sessionId, let utteranceId else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
status: .rawReady,
text: trimmed,
rawText: trimmed,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
utteranceMode: currentUtteranceMode,
aiConversationID: pendingAIConversationID
)
)
}
private func storeFinalizedError(
_ message: String,
kind: FlowSessionKeys.TranscriptionErrorKind,
sessionId: UUID?,
utteranceId: UUID?,
commandSeq: Int64,
status: FlowResult.Status = .error,
aiConversationID: UUID? = nil
) {
finishUtteranceAnalytics(
utteranceID: utteranceId,
success: false,
failureCategory: Self.transcriptionFailureCategory(
kind: kind,
status: status
)
)
guard let sessionId, let utteranceId else { return }
FlowTrace.warn(
"host.deliveredError",
"kind=\(kind.rawValue) status=\(status.rawValue) "
+ "utterance=\(utteranceId.uuidString.prefix(8)) commandSeq=\(commandSeq) "
+ "messageBytes=\(message.utf8.count)"
)
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
status: status,
text: message,
errorKind: kind,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
fieldFingerprint: Self.fieldFingerprint(pendingFieldContext),
utteranceMode: currentUtteranceMode,
aiConversationID: aiConversationID ?? pendingAIConversationID
)
)
}
private static func analyticsFeature(
for taskKind: ManagedGatewayTaskKind
) -> AnalyticsFeature {
FlowAnalyticsFeatureMapping.feature(for: taskKind)
}
private var analyticsExecutionMode: AnalyticsExecutionMode {
if store.engineMode == "local" {
return .local
}
return store.credentialSource == .managed ? .managed : .byok
}
private func finishUtteranceAnalytics(
utteranceID: UUID?,
success: Bool,
failureCategory: AnalyticsFailureCategory = .unknown
) {
if success {
analyticsOperations.succeed(utteranceID: utteranceID)
} else {
analyticsOperations.fail(
utteranceID: utteranceID,
category: failureCategory
)
}
}
private static func transcriptionFailureCategory(
kind: FlowSessionKeys.TranscriptionErrorKind,
status: FlowResult.Status
) -> AnalyticsFailureCategory {
if status == .aborted {
return .cancelled
}
if status == .timeout {
return .timeout
}
switch kind {
case .noSpeech, .discardedEmpty:
return .validation
case .recognitionInterrupted:
return .cancelled
case .asrFailed, .audioUnavailable, .generic:
return .provider
}
}
private static var lastResultRevision: Int64 = 0
private static func resultRevision() -> Int64 {
let millis = Int64(Date().timeIntervalSince1970 * 1_000)
lastResultRevision = max(lastResultRevision + 1, millis)
return lastResultRevision
}
private static func fieldFingerprint(_ context: FlowFieldContext?) -> String? {
context?.deliveryFingerprint
}
private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String {
switch mode {
case .polish:
return "polish"
case .translate(let targetLocaleId):
return "translate(\(targetLocaleId))"
}
}
private static func chunkWarningMessage(_ warnings: [String]) -> String? {
guard !warnings.isEmpty else { return nil }
return warnings.joined(separator: "\n")
}
private static func combinedWarning(_ values: String?...) -> String? {
let present = values.compactMap { value -> String? in
guard let value, !value.isEmpty else { return nil }
return value
}
return present.isEmpty ? nil : present.joined(separator: "\n")
}
private func consumeRecordingDuration() -> TimeInterval {
defer { utteranceRecordingStartedAt = nil }
guard let start = utteranceRecordingStartedAt else { return 0 }
return max(0, Date().timeIntervalSince(start))
}
/// Surface polish failures with a localised hint (e.g. missing API key)
/// rather than letting the keyboard show a generic network error.
static func makeFallbackDelivery(
rawText: String,
error: Error,
engineMode: String,
chunkWarning: String?
) -> TranscriptionDelivery {
TranscriptionPolishFallback.makeDelivery(
rawText: rawText,
error: error,
engineMode: engineMode,
chunkWarning: chunkWarning
)
}
/// Re-transcribe the full utterance PCM when pipelined chunking likely dropped tail text.
private func runBatchASRFallback(currentText: String) async -> String {
let samples = utterancePCMSamples
guard !samples.isEmpty else {
FlowTrace.warn("pipeline.batchFallback.noPCM", "currentLen=\(currentText.count)")
return currentText
}
let locale = SpeechLocaleResolver.resolve(store.localeId)
let stitched = lastFinal.trimmingCharacters(in: .whitespacesAndNewlines)
let partial = bestPartialSnapshot.trimmingCharacters(in: .whitespacesAndNewlines)
FlowDiagnostics.log(
"batch fallback start samples=\(samples.count) stitchedLen=\(stitched.count) partialLen=\(partial.count)"
)
let asrService = asr
let result = await HardTimeout.value(
seconds: FlowSessionKeys.batchASRFallbackTimeout
) { [asrService] in
await asrService.transcribeChunk(samples: samples, locale: locale)
} onTimeout: {
.cancelled
}
switch result {
case .success(let batchText):
let trimmedBatch = batchText.trimmingCharacters(in: .whitespacesAndNewlines)
FlowTrace.transcript(
"asr.batchFallback",
trimmedBatch,
"samples=\(samples.count) seconds=\(FlowTrace.seconds(samples: samples.count)) "
+ "rms=\(FlowTrace.rms(samples)) currentLen=\(currentText.count)"
)
guard !trimmedBatch.isEmpty else { return currentText }
let resolved = UtteranceBatchFallbackPolicy.preferredTranscript(
batch: trimmedBatch,
stitchedFinal: stitched,
partialSnapshot: partial,
current: currentText
)
FlowPipelineDiagnostics.logBatchFallback(
sampleCount: samples.count,
stitchedLength: stitched.count,
partialLength: partial.count,
batchLength: trimmedBatch.count
)
return resolved
case .failure(let message):
FlowDiagnostics.log(
"batch fallback failed category=asrFailure errorBytes=\(message.utf8.count)"
)
FlowTrace.warn(
"asr.batchFallback.failed",
"samples=\(samples.count) rms=\(FlowTrace.rms(samples)) "
+ "errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
return currentText
case .cancelled:
FlowTrace.asr("batchFallback.cancelled", "samples=\(samples.count)")
return currentText
}
}
private func asrWaitTimeout() -> TimeInterval {
// Local engine is iOS `SpeechAnalyzer` only, so the
// previous Qwen3-specific timeout collapses into the shared
// local path.
if store.engineMode == "local" {
return FlowSessionKeys.localASRWaitTimeout
}
return FlowSessionKeys.cloudASRWaitTimeout
}
/// Host-level polish cap — does not wait for a cancelled LLM task to unwind.
private static func polishWithHostTimeout(
polisher: PolishingService,
text: String,
mode: PolishingService.PolishMode,
systemPrompt: String? = nil,
providerIdOverride: String?,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
context: PolishContext?,
timeoutLimit: TimeInterval? = nil
) async throws -> PolishingService.PolishOutcome {
let scaled = FlowSessionKeys.polishTimeout(forCharacterCount: text.count)
let timeout = timeoutLimit.map { min(scaled, $0) } ?? scaled
return try await HardTimeout.run(seconds: timeout) {
try await polisher.polishWithOutcome(
text,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
context: context
)
}
}
// MARK: - Level publishing (main thread only)
private func startLevelPublishing() {
levelTask?.cancel()
levelTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self, self.isActive else { break }
guard self.isUtteranceRecording || self.capture.engineIsLive else {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
let levels = self.capture.currentAudioLevels()
self.pipController.updateWaveformLevels(levels)
if levels.contains(where: { $0 > 0 }) {
FlowSessionBridge.storeAudioLevels(levels)
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
}
// MARK: - Timers
private func startHeartbeat() {
heartbeatTask?.cancel()
FlowSessionBridge.writeHeartbeat()
heartbeatTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self else { break }
if self.isActive,
!self.capture.engineIsLive,
self.isUtteranceRecording || self.isUtteranceProcessing {
await self.reactivateCaptureIfNeeded()
}
if self.wantsActiveSession,
!self.pipController.isPictureInPictureActive {
if self.isAppForeground {
self.ensurePiPActive(trigger: .healthCheck, duration: nil)
} else {
self.pipLifecycleState = .waitingForForeground
// Deduped to one report per episode — this branch runs
// once a second for as long as the loss lasts.
self.reportPiPLossIfNeeded(origin: "heartbeatHealthCheck")
}
}
FlowSessionBridge.writeHeartbeat()
self.refreshHostReady()
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard self.isActive else { break }
}
}
}
private func debug(_ message: String) {
FlowDiagnostics.log(message)
}
/// Why a bounded PiP recovery gave up mid-loop. These aborts are not
/// failures from the host's point of view — but they *are* the user-visible
/// failure on the keyboard side, which sits there until its start budget
/// expires. Naming the cause is what makes the two timelines line up.
private func pipRecoveryAbortReason(operation: UInt64) -> String {
var causes: [String] = []
if operation != pipRecoveryOperation { causes.append("supersededOperation") }
if !wantsActiveSession { causes.append("sessionIntentCleared") }
if !isAppForeground { causes.append("appBackgrounded") }
if Task.isCancelled { causes.append("taskCancelled") }
return causes.isEmpty ? "unknown" : causes.joined(separator: "+")
}
private func startupFailureContext(
trigger: String,
attemptCount: Int,
elapsed: TimeInterval
) -> [String: String] {
let memory = OSGDiag.memorySnapshot()
return [
"trigger": trigger,
"attemptCount": "\(attemptCount)",
"elapsedSeconds": String(format: "%.3f", elapsed),
"appForeground": isAppForeground ? "true" : "false",
"sessionActive": isActive ? "true" : "false",
"sessionIntent": wantsActiveSession ? "true" : "false",
"pipLifecycleState": String(describing: pipLifecycleState),
"pipGeneration": "\(pipController.generation)",
"pipActive": pipController.isPictureInPictureActive ? "true" : "false",
"hostReady": FlowSessionBridge.isHostReady() ? "true" : "false",
"bridgeSessionActive": FlowSessionBridge.isSessionActive() ? "true" : "false",
"heartbeatStalenessSeconds": FlowSessionBridge.heartbeatStaleness()
.map { String(format: "%.3f", $0) } ?? "nil",
"microphonePermission": String(describing: AppPermissions.micStatus),
"speechPermission": String(describing: AppPermissions.speechStatus),
"rssMB": String(format: "%.1f", memory.rssMB),
"physicalFootprintMB": String(format: "%.1f", memory.physFootprintMB)
]
}
private static func safeErrorLogMetadata(_ error: Error) -> String {
"errorCategory=\(String(reflecting: type(of: error))) "
+ "errorBytes=\(error.localizedDescription.utf8.count)"
}
private static func aiQuestionFailureMessage(for error: Error) -> String {
if let webPageError = error as? WebPageContentError {
switch webPageError {
case .invalidURL:
return AppL10n.string("flow.error.webPageInvalid")
case .unsupportedContentType, .undecodableText, .emptyContent:
return AppL10n.string("flow.error.webPageEmpty")
case .tooManyRedirects, .invalidResponse, .httpStatus,
.responseTooLarge, .transport:
return AppL10n.string("flow.error.webPageUnavailable")
}
}
if let managedError = error as? ManagedGatewayError {
return managedError.localizedDescription
}
return AppL10n.string("flow.error.aiQuestionFailed")
}
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
guard signature != lastIgnoredCommandSignature else { return }
lastIgnoredCommandSignature = signature
traceState(
"command.ignored",
extra: "reason=\(reason) action=\(command.action.rawValue) seq=\(command.commandSeq) \(detail)"
)
}
private func traceState(_ event: String, extra: String? = nil) {
let staleness = FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil"
let sessionId = activeSessionId?.uuidString ?? "nil"
let utteranceId = currentUtteranceId?.uuidString ?? "nil"
let summary = [
"event=\(event)",
"active=\(isActive)",
"starting=\(isStarting)",
"wantsActive=\(wantsActiveSession)",
"pipState=\(String(describing: pipLifecycleState))",
"pipGeneration=\(pipController.generation)",
"coldStart=\(isColdStartHandoff)",
"sessionId=\(sessionId)",
"utteranceId=\(utteranceId)",
"cmdSeq=\(currentCommandSeq)",
"lastCmd=\(lastHandledCommandSeq)",
"recording=\(isUtteranceRecording)",
"processing=\(isUtteranceProcessing)",
"storeEngine=\(store.engineMode)",
"boundEngine=\(sessionASREngineMode ?? "nil")",
"engineLive=\(capture.engineIsLive)",
"hostReady=\(FlowSessionBridge.isHostReady())",
"sessionActive=\(FlowSessionBridge.isSessionActive())",
"heartbeatStaleness=\(staleness)"
].joined(separator: " ")
if let extra, !extra.isEmpty {
debug("[trace] \(summary) \(extra)")
} else {
debug("[trace] \(summary)")
}
}
}