// // DictationController.swift // Poche // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // import Foundation import Speech import AVFoundation import Observation /// On-device dictation. `requiresOnDeviceRecognition` is non-negotiable: /// server-side recognition would break the local promise through the back /// door (CLAUDE.md §6). If the device cannot recognize locally, the feature /// simply does not exist — no fallback. @MainActor @Observable final class DictationController { enum State: Equatable { case idle case recording case denied } private(set) var state: State = .idle private(set) var transcript = "" private let recognizer = SFSpeechRecognizer(locale: Locale.current) ?? SFSpeechRecognizer(locale: Locale(identifier: "fr_FR")) private let engine = AVAudioEngine() private var request: SFSpeechAudioBufferRecognitionRequest? private var task: SFSpeechRecognitionTask? /// Hidden entirely when local recognition is impossible. var isAvailable: Bool { recognizer?.supportsOnDeviceRecognition ?? false } func toggle() async { if state == .recording { stop() } else { await start() } } func start() async { guard let recognizer, recognizer.supportsOnDeviceRecognition else { return } // Permissions at the moment of need, never at launch (CLAUDE.md §4). let speechAuth = await withCheckedContinuation { continuation in SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) } } guard speechAuth == .authorized else { state = .denied return } guard await AVAudioApplication.requestRecordPermission() else { state = .denied return } let session = AVAudioSession.sharedInstance() try? session.setCategory(.record, mode: .measurement, options: .duckOthers) try? session.setActive(true, options: .notifyOthersOnDeactivation) let request = SFSpeechAudioBufferRecognitionRequest() request.requiresOnDeviceRecognition = true request.shouldReportPartialResults = true self.request = request let input = engine.inputNode let format = input.outputFormat(forBus: 0) // The tap runs on the audio thread; appending buffers to an active // request is the documented pattern. nonisolated(unsafe) let liveRequest = request input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in liveRequest.append(buffer) } engine.prepare() do { try engine.start() } catch { input.removeTap(onBus: 0) return } transcript = "" state = .recording task = recognizer.recognitionTask(with: request) { [weak self] result, error in let text = result?.bestTranscription.formattedString let isFinal = result?.isFinal ?? false let failed = error != nil Task { @MainActor in guard let self else { return } if let text { self.transcript = text } if isFinal || failed { self.stop() } } } } func stop() { guard state == .recording else { return } engine.stop() engine.inputNode.removeTap(onBus: 0) request?.endAudio() task?.cancel() request = nil task = nil state = .idle try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } }