896c78d260
Crashlytics: dispatch_assert_queue_fail en com.apple.root.default-qos, dentro de closure #1 in SpeechDictationService.start, llamado desde __TCCAccessRequest. El proyecto compila en Swift 6 (activo desde febrero, antes del dictado). Los callbacks de APIs ObjC pre-concurrency escritos dentro de este tipo @MainActor se infieren aislados al main actor, y Swift 6 inserta una comprobacion de aislamiento al entrar. TCC invoca el handler en cola de fondo, la comprobacion falla y mata el proceso. El Task { @MainActor } de dentro no ayuda: el crash es antes de llegar. Cuatro sitios con el mismo patron, tres enmascarados detras del primero porque nadie pasaba de los permisos: - requestAuthorization y requestRecordPermission -> wrappers nonisolated con continuation, el handler deja de estar aislado - recognitionTask -> closure @Sendable explicito; solo cruzan valores Sendable porque SFSpeechRecognitionResult no lo es - installTap -> captura el request local en vez de ir por self, que el hilo de audio en tiempo real no debe tocar estado del main actor requestAuthorization llama al handler aunque el permiso ya este concedido, asi que afectaba tambien a usuarios recurrentes: el dictado probablemente no ha funcionado nunca desde la 2.0. Los tests que acompanan NO reproducen el crash: el simulador entrega el callback sincrono en el hilo principal y el codigo previo pasa igual. Comprobado revirtiendo el fix. Queda pendiente validarlo en dispositivo real. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
154 lines
6.0 KiB
Swift
154 lines
6.0 KiB
Swift
import Foundation
|
|
import Speech
|
|
import AVFoundation
|
|
|
|
/// Live dictation wrapper around `SFSpeechRecognizer` + `AVAudioEngine`. It
|
|
/// publishes an incrementally-updated `transcript` while recording so users can
|
|
/// speak a dish name — or a whole list of ingredients in one go — instead of
|
|
/// typing. Prefers on-device recognition when the locale supports it (private,
|
|
/// offline); otherwise falls back to Apple's server recognition.
|
|
@MainActor
|
|
final class SpeechDictationService: ObservableObject {
|
|
enum State: Equatable {
|
|
case idle
|
|
case recording
|
|
case denied // microphone or speech-recognition permission refused
|
|
case unavailable // no recognizer available for the requested locale
|
|
}
|
|
|
|
@Published private(set) var state: State = .idle
|
|
@Published private(set) var transcript: String = ""
|
|
|
|
private let audioEngine = AVAudioEngine()
|
|
private var recognizer: SFSpeechRecognizer?
|
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
|
private var task: SFSpeechRecognitionTask?
|
|
|
|
var isRecording: Bool { state == .recording }
|
|
|
|
/// Requests permissions and, on success, starts live transcription in the
|
|
/// given locale. Partial results stream into `transcript`.
|
|
func start(localeIdentifier: String) {
|
|
guard state != .recording else { return }
|
|
transcript = ""
|
|
|
|
Task { @MainActor in
|
|
guard await Self.requestSpeechAuthorization() == .authorized else {
|
|
state = .denied
|
|
return
|
|
}
|
|
guard await Self.requestMicrophoneAccess() else {
|
|
state = .denied
|
|
return
|
|
}
|
|
beginSession(localeIdentifier: localeIdentifier)
|
|
}
|
|
}
|
|
|
|
// MARK: Permissions
|
|
//
|
|
// Both are pre-concurrency Objective-C APIs. A closure literal written
|
|
// inside this `@MainActor` type is inferred as main-actor isolated, and
|
|
// Swift 6 then inserts a runtime isolation check on entry. TCC invokes these
|
|
// handlers on a background queue, so that check tripped `dispatch_assert_queue`
|
|
// and killed the app the moment the user answered the permission prompt —
|
|
// every time, including when permission had already been granted.
|
|
//
|
|
// Declaring the wrappers `nonisolated` keeps the handlers free of isolation;
|
|
// the continuation resumes safely from whatever queue TCC used.
|
|
|
|
private nonisolated static func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
|
|
await withCheckedContinuation { continuation in
|
|
SFSpeechRecognizer.requestAuthorization { status in
|
|
continuation.resume(returning: status)
|
|
}
|
|
}
|
|
}
|
|
|
|
private nonisolated static func requestMicrophoneAccess() async -> Bool {
|
|
await withCheckedContinuation { continuation in
|
|
AVAudioApplication.requestRecordPermission { granted in
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func beginSession(localeIdentifier: String) {
|
|
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)),
|
|
recognizer.isAvailable else {
|
|
state = .unavailable
|
|
return
|
|
}
|
|
self.recognizer = recognizer
|
|
|
|
do {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
|
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
request.shouldReportPartialResults = true
|
|
if recognizer.supportsOnDeviceRecognition {
|
|
request.requiresOnDeviceRecognition = true
|
|
}
|
|
self.request = request
|
|
|
|
let inputNode = audioEngine.inputNode
|
|
let format = inputNode.outputFormat(forBus: 0)
|
|
inputNode.removeTap(onBus: 0)
|
|
// Captures the request directly instead of reaching through `self`:
|
|
// this runs on the realtime audio thread, which must never touch
|
|
// main-actor state. `append` is designed to be fed from that thread.
|
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
|
request.append(buffer)
|
|
}
|
|
|
|
audioEngine.prepare()
|
|
try audioEngine.start()
|
|
state = .recording
|
|
|
|
// Same pre-concurrency shape as the permission handlers: this is
|
|
// called off the main thread, so the closure must not be isolated.
|
|
// The result is not Sendable, so only plain values cross the hop.
|
|
task = recognizer.recognitionTask(with: request) { @Sendable [weak self] result, error in
|
|
let text = result?.bestTranscription.formattedString
|
|
let hasFinished = error != nil || (result?.isFinal ?? false)
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
if let text {
|
|
self.transcript = text
|
|
}
|
|
if hasFinished {
|
|
self.teardownAudio()
|
|
if self.state == .recording { self.state = .idle }
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "speech_dictation_start")
|
|
teardownAudio()
|
|
state = .idle
|
|
}
|
|
}
|
|
|
|
/// Stops capture, keeping the last transcript, and returns the final text.
|
|
@discardableResult
|
|
func stop() -> String {
|
|
teardownAudio()
|
|
if state == .recording { state = .idle }
|
|
return transcript
|
|
}
|
|
|
|
private func teardownAudio() {
|
|
if audioEngine.isRunning {
|
|
audioEngine.stop()
|
|
audioEngine.inputNode.removeTap(onBus: 0)
|
|
}
|
|
request?.endAudio()
|
|
task?.cancel()
|
|
request = nil
|
|
task = nil
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
}
|
|
}
|