TranscripteurVosk.kt 141 lignes · 5673 octets
package fr.ebii.card2vcf.audio

import android.content.Context
import android.util.Log
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import org.vosk.Model
import org.vosk.Recognizer
import java.io.File

/**
 * Transcription locale via le moteur Vosk (modèle français small ~41 Mo).
 *
 * Sur le premier appel à [accepterEchantillons], le modèle est extrait des assets
 * vers [Context.filesDir] si ce n'est pas déjà fait.
 *
 * Si le modèle n'est ni dans les assets ni dans `filesDir` (saveur `fdroid` sans
 * téléchargement), [etat] vaut
 * [TranscripteurLocal.Etat.INDISPONIBLE] et toutes les méthodes de transcription
 * renvoient des valeurs vides/null sans planter.
 */
class TranscripteurVosk(private val context: Context) : TranscripteurLocal {

    companion object {
        private const val TAG = "TranscripteurVosk"
        private const val NOM_MODELE = "vosk-model-small-fr-0.22"
        private const val FREQUENCE = 16_000f

        private val json = Json { ignoreUnknownKeys = true }

        /**
         * Extrait la valeur de la clé "partial" du JSON Vosk.
         * Retourne null si le texte est vide ou le JSON invalide.
         */
        fun parsagePartiel(jsonStr: String): String? = runCatching {
            (json.parseToJsonElement(jsonStr).jsonObject["partial"] as? JsonPrimitive)
                ?.content
                ?.takeIf { it.isNotEmpty() }
        }.getOrNull()

        /**
         * Extrait la valeur de la clé "text" du JSON Vosk.
         * Retourne une chaîne vide si le JSON est invalide.
         */
        fun parsageTexte(jsonStr: String): String = runCatching {
            (json.parseToJsonElement(jsonStr).jsonObject["text"] as? JsonPrimitive)
                ?.content
                ?: ""
        }.getOrDefault("")
    }

    override val etat: TranscripteurLocal.Etat by lazy { detecterEtat() }

    private var modele: Model? = null
    private var reconnaisseur: Recognizer? = null

    private fun detecterEtat(): TranscripteurLocal.Etat {
        val dansAssets = runCatching {
            context.assets.list("")?.contains(NOM_MODELE) == true
        }.getOrDefault(false)
        // Saveur `fdroid` : rien dans les assets, mais le modèle a pu être téléchargé
        // depuis Paramètres → Modèles et décompressé ici par DepotModeles.
        val dansFilesDir = File(context.filesDir, NOM_MODELE)
            .let { it.isDirectory && !it.listFiles().isNullOrEmpty() }
        val disponible = dansAssets || dansFilesDir
        if (!disponible) {
            Log.w(TAG, "Modèle Vosk absent — transcription serveur requise tant qu'il n'est pas téléchargé")
        }
        return if (disponible) TranscripteurLocal.Etat.DISPONIBLE else TranscripteurLocal.Etat.INDISPONIBLE
    }

    /** Retourne le [Recognizer] prêt à l'emploi, ou null si modèle indisponible. */
    private fun obtenirReconnaisseur(): Recognizer? {
        if (etat == TranscripteurLocal.Etat.INDISPONIBLE) return null
        if (reconnaisseur != null) return reconnaisseur
        return runCatching {
            val repModele = extraireModele()
            val m = Model(repModele.absolutePath).also { modele = it }
            Recognizer(m, FREQUENCE).also { reconnaisseur = it }
        }.onFailure { e ->
            Log.e(TAG, "Impossible d'initialiser le moteur Vosk", e)
        }.getOrNull()
    }

    /** Retourne le répertoire du modèle dans filesDir, après extraction si nécessaire. */
    private fun extraireModele(): File {
        val dest = File(context.filesDir, NOM_MODELE)
        if (dest.isDirectory && dest.list()?.isNotEmpty() == true) return dest
        Log.i(TAG, "Extraction du modèle Vosk vers ${dest.absolutePath} …")
        copierRepertoireAssets(NOM_MODELE, dest)
        Log.i(TAG, "Modèle Vosk extrait.")
        return dest
    }

    private fun copierRepertoireAssets(cheminAsset: String, destDir: File) {
        destDir.mkdirs()
        val enfants = context.assets.list(cheminAsset) ?: return
        for (enfant in enfants) {
            val sousAsset = "$cheminAsset/$enfant"
            val sousDest = File(destDir, enfant)
            val sousEnfants = context.assets.list(sousAsset) ?: emptyArray()
            if (sousEnfants.isEmpty()) {
                // fichier feuille
                context.assets.open(sousAsset).use { input ->
                    sousDest.outputStream().use { input.copyTo(it) }
                }
            } else {
                copierRepertoireAssets(sousAsset, sousDest)
            }
        }
    }

    override fun accepterEchantillons(data: ShortArray, longueur: Int): String? {
        val rec = obtenirReconnaisseur() ?: return null
        val octets = shortArrayVersOctets(data, longueur)
        val complet = rec.acceptWaveForm(octets, octets.size)
        return if (complet) parsageTexte(rec.result) else parsagePartiel(rec.partialResult)
    }

    override fun finaliser(): String {
        val rec = reconnaisseur ?: return ""
        return parsageTexte(rec.finalResult)
    }

    override fun reinitialiser() {
        reconnaisseur?.close()
        reconnaisseur = null
    }

    /** Convertit un tableau de Short PCM en ByteArray little-endian. */
    private fun shortArrayVersOctets(data: ShortArray, longueur: Int): ByteArray {
        val octets = ByteArray(longueur * 2)
        for (i in 0 until longueur) {
            val s = data[i].toInt()
            octets[i * 2] = (s and 0xFF).toByte()
            octets[i * 2 + 1] = (s ushr 8 and 0xFF).toByte()
        }
        return octets
    }
}