SPB Git

spb/poche Public

Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.

Swift 100%
3.6 KB · 118 lines swift
Raw Blame History
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