Files
FamilyMealPlanner/MealMood/Services/SpeechDictationService.swift
T
alexandrev-tibco 39d3534c57 fix: el tap del microfono seguia aislado al main actor (2.0.3 build 68)
El fix anterior arreglo los permisos —el crash se movio de start a beginSession,
y de TCC a AVAudioNodeTap::CheckEmitBuffer— pero el tap seguia petando.

Mi error: quitar el acceso a self del closure no elimina la inferencia de
aislamiento. La inferencia viene de DONDE se escribe el closure, no de lo que
captura. Escrito dentro de un metodo @MainActor, seguia siendo main-actor
isolated, y el tap se dispara desde el hilo de audio en tiempo real.

Ahora el handler se construye en makeTapHandler, que es nonisolated, y se pasa a
installTap. La estructura se auto-verifica: quitarle nonisolated a makeTapHandler
no compila, porque installTap es nonisolated y no puede llamar a un metodo
aislado. El bug pasa de crash en produccion a error de compilacion.

Tests: testTapHandlerRunsOffTheMainThread invoca el handler desde una cola de
fondo, que es exactamente la condicion que trapeaba. No hace falta microfono; el
intento anterior con AVAudioEngine se saltaba siempre porque el simulador no
tiene entrada de audio utilizable.

Barrido del mismo patron en el resto de servicios @MainActor: CalendarService y
NotificationService usan las variantes async/await, que estan anotadas y no
tienen closures. SpeechDictationService era el unico sitio.

Lanes feedback y crashlog para leer los reportes de TestFlight desde la API:
spaceship apunta a v1/betaFeedbacks, que Apple ya retiro; el endpoint vivo es
v1/apps/<id>/betaFeedbackCrashSubmissions y el log viene inline en logText.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
2026-08-20 09:21:52 +02:00

178 lines
7.2 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)
}
}
}
/// Installs the microphone tap from a `nonisolated` context.
///
/// `AVAudioNodeTapBlock` is pre-concurrency, so a closure literal written
/// inside a `@MainActor` method is inferred main-actor isolated and the tap
/// is fired from the realtime audio thread, so Swift 6's isolation check
/// trapped there. Dropping the `self` access was not enough: the inference
/// comes from where the closure is *written*, not from what it captures.
/// Declaring these helpers `nonisolated` is what actually removes it.
///
/// The handler is built here, in a `nonisolated` context, and handed to
/// `installTap` rather than written inline at the call site that is the
/// whole point, and it is also what makes it testable: a test can call this
/// and invoke the result off the main thread, which is exactly the condition
/// that trapped, without needing a working microphone.
nonisolated static func makeTapHandler(
feeding request: SFSpeechAudioBufferRecognitionRequest
) -> (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in
request.append(buffer)
}
}
nonisolated static func installTap(on node: AVAudioInputNode,
format: AVAudioFormat,
feeding request: SFSpeechAudioBufferRecognitionRequest) {
node.installTap(onBus: 0, bufferSize: 1024, format: format,
block: makeTapHandler(feeding: request))
}
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)
Self.installTap(on: inputNode, format: format, feeding: request)
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)
}
}