Files
FamilyMealPlanner/MealMood/Services/SpeechDictationService.swift
T
alexandrev-tibco 896c78d260 fix: crash al dictar por closures de callbacks aislados al main actor
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
2026-08-19 21:28:29 +02:00

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)
}
}