39d3534c57
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
178 lines
7.2 KiB
Swift
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)
|
|
}
|
|
}
|