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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user