spb/poche Public
Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.
Swift 100%
1//2// DictationController.swift3// Poche4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//89import Foundation10import Speech11import AVFoundation12import Observation1314/// On-device dictation. `requiresOnDeviceRecognition` is non-negotiable:15/// server-side recognition would break the local promise through the back16/// door (CLAUDE.md §6). If the device cannot recognize locally, the feature17/// simply does not exist — no fallback.18@MainActor19@Observable20final class DictationController {21 enum State: Equatable {22 case idle23 case recording24 case denied25 }2627 private(set) var state: State = .idle28 private(set) var transcript = ""2930 private let recognizer = SFSpeechRecognizer(locale: Locale.current)31 ?? SFSpeechRecognizer(locale: Locale(identifier: "fr_FR"))32 private let engine = AVAudioEngine()33 private var request: SFSpeechAudioBufferRecognitionRequest?34 private var task: SFSpeechRecognitionTask?3536 /// Hidden entirely when local recognition is impossible.37 var isAvailable: Bool {38 recognizer?.supportsOnDeviceRecognition ?? false39 }4041 func toggle() async {42 if state == .recording {43 stop()44 } else {45 await start()46 }47 }4849 func start() async {50 guard let recognizer, recognizer.supportsOnDeviceRecognition else { return }5152 // Permissions at the moment of need, never at launch (CLAUDE.md §4).53 let speechAuth = await withCheckedContinuation { continuation in54 SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }55 }56 guard speechAuth == .authorized else {57 state = .denied58 return59 }60 guard await AVAudioApplication.requestRecordPermission() else {61 state = .denied62 return63 }6465 let session = AVAudioSession.sharedInstance()66 try? session.setCategory(.record, mode: .measurement, options: .duckOthers)67 try? session.setActive(true, options: .notifyOthersOnDeactivation)6869 let request = SFSpeechAudioBufferRecognitionRequest()70 request.requiresOnDeviceRecognition = true71 request.shouldReportPartialResults = true72 self.request = request7374 let input = engine.inputNode75 let format = input.outputFormat(forBus: 0)76 // The tap runs on the audio thread; appending buffers to an active77 // request is the documented pattern.78 nonisolated(unsafe) let liveRequest = request79 input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in80 liveRequest.append(buffer)81 }8283 engine.prepare()84 do {85 try engine.start()86 } catch {87 input.removeTap(onBus: 0)88 return89 }9091 transcript = ""92 state = .recording9394 task = recognizer.recognitionTask(with: request) { [weak self] result, error in95 let text = result?.bestTranscription.formattedString96 let isFinal = result?.isFinal ?? false97 let failed = error != nil98 Task { @MainActor in99 guard let self else { return }100 if let text { self.transcript = text }101 if isFinal || failed { self.stop() }102 }103 }104 }105106 func stop() {107 guard state == .recording else { return }108 engine.stop()109 engine.inputNode.removeTap(onBus: 0)110 request?.endAudio()111 task?.cancel()112 request = nil113 task = nil114 state = .idle115 try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)116 }117}118