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