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 = "" SFSpeechRecognizer.requestAuthorization { [weak self] speechAuth in guard let self else { return } Task { @MainActor in guard speechAuth == .authorized else { self.state = .denied; return } self.requestMicAndStart(localeIdentifier: localeIdentifier) } } } private func requestMicAndStart(localeIdentifier: String) { AVAudioApplication.requestRecordPermission { [weak self] granted in guard let self else { return } Task { @MainActor in guard granted else { self.state = .denied; return } self.beginSession(localeIdentifier: localeIdentifier) } } } 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) inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in self?.request?.append(buffer) } audioEngine.prepare() try audioEngine.start() state = .recording task = recognizer.recognitionTask(with: request) { [weak self] result, error in guard let self else { return } Task { @MainActor in if let result { self.transcript = result.bestTranscription.formattedString } if error != nil || (result?.isFinal ?? false) { 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) } }