feat: harden Flow cold-start/force-quit and polish macOS dictation UX

Fix cold-start overlay recursion that overflowed the main-thread stack when
recording began while the ready overlay was still up; also remove temporary
on-screen Flow DEBUG panels after the orange-mic investigation, and land the
macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
+99 -5
View File
@@ -9,10 +9,65 @@ import Foundation
import Speech
enum MacSpeechLocalASR {
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
/// Shared resume-once state for one recognition run. The recognizer
/// callback (delivered on an arbitrary Speech queue) and the timeout task
/// race to finish, and a `CheckedContinuation` must resume exactly once,
/// so both go through this lock-guarded gate. It also retains the
/// `SFSpeechRecognitionTask` so the losing/failing path can cancel it.
private final class RecognitionSession: @unchecked Sendable {
private let lock = NSLock()
private var isResumed = false
private var task: SFSpeechRecognitionTask?
private var timeoutTask: Task<Void, Never>?
func retain(_ task: SFSpeechRecognitionTask) {
lock.lock()
self.task = task
let alreadyResumed = isResumed
lock.unlock()
// Timeout won the race before the task handle was stored.
if alreadyResumed { task.cancel() }
}
func retainTimeout(_ task: Task<Void, Never>) {
lock.lock()
timeoutTask = task
let alreadyResumed = isResumed
lock.unlock()
// Recognition finished before the handle landed stop the timer.
if alreadyResumed { task.cancel() }
}
/// Returns `true` exactly once across all callers; the winner may
/// resume the continuation. Pass `cancellingTask: true` on failure
/// paths so the in-flight recognition stops doing work. The winner
/// also cancels the timeout task so it doesn't keep the session (and
/// continuation captures) alive for the rest of its sleep.
func claimResume(cancellingTask: Bool) -> Bool {
lock.lock()
guard !isResumed else {
lock.unlock()
return false
}
isResumed = true
let task = self.task
let timeout = timeoutTask
lock.unlock()
if cancellingTask { task?.cancel() }
timeout?.cancel()
return true
}
}
static func transcribe(samples: [Float], locale: Locale, bias: LocalASRBiasPayload? = nil) async throws -> String {
let auth = await requestAuthorization()
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
if Self.isChineseLocale(locale) {
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
_ = try? await CustomLanguageModelManager.shared.prepareIfNeeded()
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
defer { try? FileManager.default.removeItem(at: wavURL) }
@@ -20,18 +75,43 @@ enum MacSpeechLocalASR {
guard let recognizer, recognizer.isAvailable else {
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
}
// The request below sets `requiresOnDeviceRecognition = true`, which
// fails (or worse, never produces a final result) when the on-device
// model for the locale is missing fail fast with a clear error.
guard recognizer.supportsOnDeviceRecognition else {
throw MacLocalASRError.speechFailed(
"On-device speech recognition is not available for \(recognizer.locale.identifier). Download the language in System Settings → Keyboard → Dictation."
)
}
return try await withCheckedThrowingContinuation { continuation in
// Overall deadline: recognition of a file is normally much faster than
// realtime, so 2× audio length with a 30 s floor is generous. Without
// it, empty audio / cancellation / a missing model can leave the
// callback silent forever and the continuation never resumes.
let audioSeconds = Double(samples.count) / 16_000
let timeoutSeconds = max(30.0, audioSeconds * 2)
let session = RecognitionSession()
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
let request = SFSpeechURLRecognitionRequest(url: wavURL)
request.shouldReportPartialResults = false
request.requiresOnDeviceRecognition = true
CustomLanguageModelManager.applyCustomLanguageModel(
to: request,
locale: locale,
bias: bias
)
recognizer.recognitionTask(with: request) { result, error in
let task = recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
if session.claimResume(cancellingTask: true) {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
}
return
}
// Non-final callbacks carry no usable transcript yet; if a
// final result never arrives, the timeout below resumes us.
guard let result, result.isFinal else { return }
guard session.claimResume(cancellingTask: false) else { return }
let text = result.bestTranscription.formattedString
.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
@@ -40,6 +120,16 @@ enum MacSpeechLocalASR {
continuation.resume(returning: text)
}
}
session.retain(task)
let timeout = Task {
try? await Task.sleep(for: .seconds(timeoutSeconds))
guard !Task.isCancelled else { return }
if session.claimResume(cancellingTask: true) {
continuation.resume(throwing: MacLocalASRError.speechFailed("Speech recognition timed out"))
}
}
session.retainTimeout(timeout)
}
}
@@ -58,4 +148,8 @@ enum MacSpeechLocalASR {
try wav.write(to: url)
return url
}
private static func isChineseLocale(_ locale: Locale) -> Bool {
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
}
}