TranscripteurSpeech.swift 99 lignes · 3329 octets
import AVFoundation
import Speech

/// Transcription locale via SFSpeechRecognizer (on-device, fr_FR).
///
/// `requiresOnDeviceRecognition = true` garantit que les données audio
/// ne quittent pas l'appareil.
///
/// Gère gracieusement le refus de permission et l'indisponibilité du moteur :
/// `etat` vaut `.indisponible` dans ces cas, et `accepterEchantillons` retourne `nil`.
final class TranscripteurSpeech: TranscripteurLocal {

    private let reconnaissance: SFSpeechRecognizer?
    private var requete: SFSpeechAudioBufferRecognitionRequest?
    private var tache: SFSpeechRecognitionTask?
    private var dernierPartiel: String = ""
    private var texteFinal: String = ""

    // Float32 non-entrelacé : format le plus compatible avec SFSpeechRecognizer.
    private let formatFloat: AVAudioFormat = AVAudioFormat(
        commonFormat: .pcmFormatFloat32,
        sampleRate: 16_000,
        channels: 1,
        interleaved: false
    )!

    init() {
        reconnaissance = SFSpeechRecognizer(locale: Locale(identifier: "fr_FR"))
        reconnaissance?.defaultTaskHint = .dictation
    }

    // MARK: - TranscripteurLocal

    var etat: EtatTranscripteur {
        guard let r = reconnaissance, r.isAvailable else { return .indisponible }
        guard SFSpeechRecognizer.authorizationStatus() == .authorized else { return .indisponible }
        return .disponible
    }

    func accepterEchantillons(_ data: [Int16]) -> String? {
        guard etat == .disponible else { return nil }

        // Démarre la tâche de reconnaissance au premier appel de la session.
        if requete == nil {
            demarrerTache()
        }

        if let buffer = creerBuffer(depuis: data) {
            requete?.append(buffer)
        }

        return dernierPartiel.isEmpty ? nil : dernierPartiel
    }

    func finaliser() -> String {
        requete?.endAudio()
        return texteFinal.isEmpty ? dernierPartiel : texteFinal
    }

    func reinitialiser() {
        tache?.cancel()
        tache = nil
        requete = nil
        dernierPartiel = ""
        texteFinal = ""
    }

    // MARK: - Privé

    private func demarrerTache() {
        let req = SFSpeechAudioBufferRecognitionRequest()
        req.requiresOnDeviceRecognition = true
        req.shouldReportPartialResults = true
        requete = req

        tache = reconnaissance?.recognitionTask(with: req) { [weak self] result, _ in
            guard let self, let result else { return }
            let texte = result.bestTranscription.formattedString
            if result.isFinal {
                self.texteFinal = texte
                self.dernierPartiel = ""
            } else {
                self.dernierPartiel = texte
            }
        }
    }

    /// Convertit un tableau Int16 en AVAudioPCMBuffer Float32 pour SFSpeechRecognizer.
    private func creerBuffer(depuis data: [Int16]) -> AVAudioPCMBuffer? {
        let frameCount = AVAudioFrameCount(data.count)
        guard let buffer = AVAudioPCMBuffer(pcmFormat: formatFloat, frameCapacity: frameCount),
              let channelData = buffer.floatChannelData else { return nil }
        buffer.frameLength = frameCount
        for (i, sample) in data.enumerated() {
            channelData[0][i] = Float(sample) / 32_768.0
        }
        return buffer
    }
}