Kazeia-engine/dist/STT_INTEGRATION.md

15 KiB
Raw Blame History

Intégration STT Kazeia-Engine — Guide développeur

Date : 31/05/2026 Cible : remplacer WhisperHybridEngine.kt (527 l) + MelExtractor.kt + VadStage.kt + libmel_extractor.so par la lib unifiée libkazeia_stt.so + façade Kotlin SttEngine.kt.

Runtime : ONNX Runtime 1.24.3 + QNN ExecutionProvider HTP V79 — inchangé. Pas de régression NPU (les contextes QAIRT Qualcomm 545 MB sont conservés tels quels).

Perf de référence (Snapdragon 8 Elite / SM8750, Whisper-Small FR, warm path, après fix DFT 400 + forced prompt SOT/lang/task) :

Cas réel Kazeia (audios ~3 s parole dense, mesuré directement sur la tablette) :

Étape Prod Kotlin (22 tok / 1.6 s) C++ unifié (21 tok mesuré sur 3 s)
Mel 189 ms 220-320 ms (DFT directe N_FFT=400, obligatoire)
Encoder 125 ms 60-86 ms
Decoder/token 23 ms 12-13 ms
Decoder total 506 ms (22 tok) 252 ms (21 tok)
Total ~820 ms (estimé) ~620 ms (mesuré 21 tok / 3 s)
RAM peak 545 MB 377 MB (-31 %)
A/B accuracy in-app référence à mesurer par dev (cf §9.2)

Honnêteté sur les chiffres : les annonces précédentes (-45 %, mel 60 ms) cumulaient un bug. Le mel devait utiliser DFT directe N=400 (pas FFT zero-pad 512) sinon les bins du spectre sont désalignés en fréquence par rapport à mel_filters.json (40 Hz/bin attendu vs 31.25 Hz/bin produit). Sur audios à hautes harmoniques (voix féminines, certaines voix), cela causait l'encoder à produire un cross-attention "no speech" → decoder sortait <EOT> immédiat → transcription vide. Bug fix v2 dans la session 01/06.

Bug fix v3 — forced decoder prompt : sur certains audios borderline, Whisper saute l'étape « prédire la langue » et choisit <|notimestamps|> direct → EOT au step 1. Solution standard HF : forcer le prompt <SOT, |lang|, |transcribe|> puis laisser le modèle décider du mode timestamps. PAS forcer <|notimestamps|> (le decoder QAIRT préfère le mode timestamps).

Cas audio long (10-15 s, parole continue, montre l'amortissement encoder) :

Audio Tokens Total RTF Decoder/tok
10 s richard FR 39 1390 ms 0.14 36 ms
15 s richard FR 71 896 ms (warm) 0.06 10 ms

Clé du gain : options QNN explicites (cf §X plus bas). Sans ces options, l'encoder est ~5× plus lent (par défaut QNN tourne en mode default au lieu de burst, n'identifie pas l'arch V79, n'active pas le fp16 natif).

Decoder/token 14 ms (-39 % vs prod 23 ms) grâce à :

  1. Options QNN : burst + v79 + enable_htp_fp16_precision = NPU à fréquence max
  2. Zero-copy : Ort::Value persistants pointant sur les buffers self_k/v (pas de vector::emplace_back qui copiait 12 MB/step)

Vérifs sanity au runtime :

  • Logits shape = 51865 (= constante VOCAB_SIZE), pas de buffer overread
  • 5 runs warm successifs : encoder 60-63 ms stable, decoder 160-162 ms stable, RAM 377 MB inchangée
  • Transcription FR cohérente sur 10+ audios différents (damien, richard 1.6/3/5/10/15 s)
  • Texte produit lisible et plausible. A/B comparison vs WhisperHybridEngine Kotlin sur les mêmes fichiers reste à faire côté app — c'est le seul moyen de quantifier le drift FP16 NPU éventuel (cf checklist §9 point 2).

* Decoder et encoder C++ légèrement plus lents qu'en ORT Java à cause d'overhead memcpy fp16 ; optim future via IO bindings ORT. Le RTF global reste sous la cible.


1. Ce que la lib unifiée fait

libkazeia_stt.so  (168 KB, SHARED)
 ├── stt_engine_load(model_dir, use_htp, n_threads, max_decode_steps) -> handle
 ├── stt_engine_transcribe(handle, pcm16, sample_rate, language, force_transcribe)
 │       -> { text, mel_ms, encoder_ms, decoder_ms, total_ms, n_tokens, rtf }
 ├── stt_engine_free(handle)
 ├── stt_vad_new/push/reset/free  (VAD RMS énergie, drop-in replacement VadStage.kt)
 └── kazeia_mel (mel spectrogram FFT, partagé avec speaker encoder TTS)

Côté Kotlin, l'API est dans SttEngine.kt (façade thin) :

val stt = SttEngine(modelDir = "/data/.../whisper-small-sm8750", useHtp = true, nThreads = 6)
val r = stt.transcribe(pcm16, language = "fr")
println("${r.text}  [${r.totalMs} ms, RTF ${r.rtf}, ${r.nTokens} tokens]")
stt.release()

Et la VAD :

val vad = SttVad()  // 16 kHz, 100 ms frames, seuil 150, 300/800 ms
val buf = mutableListOf<Short>()
audioRecord.readContinuously { chunk ->
    when (vad.push(chunk)) {
        SttVad.SPEECH        -> buf.addAll(chunk.toList())
        SttVad.END_OF_SPEECH -> {
            val r = stt.transcribe(buf.toShortArray())
            handleTranscription(r.text)
            buf.clear(); vad.reset()
        }
    }
}

2. Build (côté app Android)

a. Récupérer les artifacts

Sur le host (machine de build) :

cd /opt/Kazeia-engine/dist
bash build_kazeia_stt.sh
# produit b-jni/libkazeia_stt.so

b. Inclure dans l'app

Copie dans app/src/main/jniLibs/arm64-v8a/ :

Source Destination Taille
dist/b-jni/libkazeia_stt.so jniLibs/arm64-v8a/libkazeia_stt.so 168 KB
dist/lib/libonnxruntime.so jniLibs/arm64-v8a/libonnxruntime.so 20 MB

Note : libonnxruntime.so et les libs libQnn*.so sont déjà chargées par l'app via les dépendances Maven onnxruntime-android-qnn:1.24.3 et qnn-runtime:2.44.0garde ces deux deps dans build.gradle.kts. La lib libkazeia_stt.so partage le même ORT runtime.

c. Copier la façade Kotlin

Copie dist/jni/SttEngine.kt dans app/src/main/java/com/kazeia/stt/SttEngine.kt.

Le package est déjà com.kazeia.stt (cohérent avec WhisperHybridEngine actuel). Les classes SttJni, SttEngine, SttVad, et SttResult y sont déclarées.

d. Modèles sur la tablette

Inchangé par rapport à la prod actuelle. Push dans le même dir :

/data/local/tmp/kazeia/models/whisper-small-sm8750/
├── HfWhisperEncoder.onnx
├── HfWhisperEncoder_qairt_context.bin       (201 MB)
├── HfWhisperDecoder.onnx
├── HfWhisperDecoder_qairt_context.bin       (345 MB)
├── mel_filters.json                          (87 KB)
├── vocab.json                                (1 MB)
└── (optionnel) mel_filters.bin              (64 KB, plus rapide à charger que JSON)

Pour générer mel_filters.bin une fois pour toutes (gain ~50 ms au load) :

import json, numpy as np
arr = np.array(json.load(open('mel_filters.json')), dtype=np.float32).reshape(80, 201)
arr.tofile('mel_filters.bin')

3. Migration depuis WhisperHybridEngine

Fichiers à supprimer de l'app

Fichier Pourquoi
app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt (527 l) Remplacé par SttEngine.kt
app/src/main/java/com/kazeia/stt/MelExtractor.kt (18 l) Remplacé par le mel C++ dans libkazeia_stt.so
app/src/main/java/com/kazeia/v2/VadStage.kt (197 l) Remplacé par SttVad class
app/src/main/jni/mel_extractor.cpp (203 l) Idem
app/src/main/jniLibs/arm64-v8a/libmel_extractor.so Idem
Build entry mel_extractor dans app/src/main/jni/CMakeLists.txt Idem

Fichiers à adapter

SttStage.kt (app/src/main/java/com/kazeia/v2/SttStage.kt) — la classe wrapper. Remplace l'appel à WhisperHybridEngine par SttEngine :

Avant :

private val engine = WhisperHybridEngine(nativeLibDir = ctx.applicationInfo.nativeLibraryDir)
engine.load(modelPath = WHISPER_DIR)
// ...
val result = engine.transcribe(audio, language)

Après :

private val engine = SttEngine(modelDir = WHISPER_DIR, useHtp = true, nThreads = 6)
// load se fait au constructeur
// ...
val r = engine.transcribe(audio, language = language)
val result = TranscriptionResult(r.text, 0.95f, language, r.totalMs)

KazeiaService.kt (v1, ligne 538 environ) : pareil — remplace WhisperHybridEngine par SttEngine. La VAD inline RMS dans startContinuousListening() peut être remplacée par SttVad (drop-in identique : seuil 150, frame 1600, 3/8 frames).

SttEngine.kt interface (app/src/main/java/com/kazeia/core/SttEngine.kt) : si vous voulez garder une abstraction (pour fallback futur), la classe com.kazeia.stt.SttEngine peut implémenter cette interface trivialement :

override suspend fun transcribe(audioData: ShortArray, language: String): TranscriptionResult {
    val r = engine.transcribe(audioData, language = language)
    return TranscriptionResult(r.text, 0.95f, language, r.totalMs)
}

4. Variables d'environnement runtime

Variable Effet Défaut
ADSP_LIBRARY_PATH Chemin où QNN cherche les libs HTP (skel/stub V79). Pour Android, c'est jniLibs/arm64-v8a/ qui est dans le PATH par défaut. Pas besoin de set manuellement. auto
LD_LIBRARY_PATH Idem pour libonnxruntime.so. Auto sur Android. auto

Aucune variable d'env spécifique requise — l'app Kotlin charge libkazeia_stt.so via System.loadLibrary("kazeia_stt") (déclenché par SttJni.companion init), qui résout les deps via le dynamic linker Android (jniLibs/).


5. Format du résultat JNI (interne)

Si tu veux debugger ou wrapper différemment :

SttJni.nativeTranscribe(...) renvoie une String au format pipe-séparé :

err|text|mel_ms|encoder_ms|decoder_ms|total_ms|n_tokens|rtf

Exemple : "0|Elle mat est un casque de chantier.|60|311|1602|1991|16|0.398"

err=0 = success. Sinon < 0 (voir codes dans stt_engine.cpp).

Le texte est en UTF-8 brut (Whisper BPE byte-level décodé). Le split Kotlin utilise limit=8 pour préserver un éventuel | parasite dans le texte (pas observé en pratique sur FR/EN).


6. Codes d'erreur

Code Signification
0 OK
-1 engine nul / pcm nul / n_samples ≤ 0
-2 engine non chargé
-3 mel compute fail
-4 encoder Run fail (ORT exception, voir logcat)
-5 nom input decoder inconnu (modèle non standard)
-6 decoder Run fail (ORT exception)
-7 sortie logits absente
-10 sample_rate != 16000

Au load (stt_engine_load renvoie nullptr) :

  • mel_basis absent (.bin ni .json)
  • Ort env / session creation fail (souvent : EPContext en mode CPU pur — utilise useHtp=true)
  • vocab.json absent ou parse fail

7. Bench rapide (sur tablette via adb shell)

adb push dist/b-jni/kazeia_stt_cli /data/local/tmp/stt/bin/
adb push dist/lib/libonnxruntime.so /data/local/tmp/stt/bin/
adb push dist/lib/qnn/*.so /data/local/tmp/stt/bin/      # QNN libs

adb shell '
cd /data/local/tmp/stt
LD_LIBRARY_PATH=./bin ADSP_LIBRARY_PATH=./bin \
  ./bin/kazeia_stt_cli ./models ./audio/sample_fr_16k.wav fr htp
'

Sortie typique sur Snapdragon 8 Elite :

=== TRANSCRIPTION (NPU, fr) ===
 Bonjour, comment ça va ?
=== TIMING ===
mel       60 ms
encoder  311 ms
decoder  302 ms  (8 tokens)
total    684 ms  for 2.50 s audio  =>  RTF 0.274

7bis. Options QNN HTP V79 — critique pour la perf

Le code C++ active explicitement ces options dans stt_engine_load (différence avec la prod Kotlin qui les laisse par défaut) :

opts.AppendExecutionProvider("QNN", {
    {"backend_path",              "libQnnHtp.so"},
    {"htp_performance_mode",      "burst"},        // NPU freq max (vs default = balanced)
    {"htp_arch",                  "79"},           // SM8750 explicite
    {"enable_htp_fp16_precision", "1"},            // NPU fp16 natif
    {"profiling_level",           "off"},
    {"rpc_control_latency",       "100"},
});
opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
opts.DisableMemPattern();

Impact mesuré : sans ces options, encoder à 309 ms et decoder à 48 ms/token. Avec, encoder à 62 ms et decoder à 14 ms/token (-80 % et -71 %).

Recommandation pour le prod Kotlin actuel : ajouter ces options dans WhisperHybridEngine.kt aussi pour gain immédiat même sans migration. Syntaxe Java :

val encOpts = OrtSession.SessionOptions()
encOpts.addQnn(mapOf(
    "backend_path"              to htpPath,
    "htp_performance_mode"      to "burst",
    "htp_arch"                  to "79",
    "enable_htp_fp16_precision" to "1",
))
encOpts.optimizationLevel = OrtSession.SessionOptions.OptLevel.ALL_OPT

8. Notes pour optim future (non bloquant, opt-in)

Le decoder C++ est ~1.5× plus lent par token que la prod Kotlin (36 ms vs 23 ms). Reste de l'écart probablement dans :

  • L'argmax FP16 C++ vs Java (Java pourrait avoir vectorisation auto)
  • Différences d'optim ORT C++ vs Java (jvm hot path)
  • Le memcpy des self KV outputs (24 × ~300 KB) — pourrait être éliminé via Ort::IoBinding (pré-alloue les buffers de sortie une fois pour toutes et ORT écrit dedans, plus de memcpy needed)

Estimation gain IoBinding : ~30 % sur le decoder = decoder 25 ms/token, RTF ~0.13. Optim qui peut attendre.

Optim déjà appliquée (8f62532 → ce livrable) : zero-copy sur les inputs decoder (Ort::Value persistants pointant sur les buffers, pas de vector::emplace_back qui dupliquait 12 MB à chaque step). Gain mesuré : decoder/token 98 ms → 36 ms (-63 %), RTF 0.42 → 0.18 sur 10 s audio.


9. Checklist d'intégration côté app (à vérifier en premier)

Ces points sont vérifiés en bench standalone mais pas dans une vraie JVM Android, donc à valider en premier dans l'app :

  1. System.loadLibrary("kazeia_stt") réussit au démarrage de l'app — vérifie qu'aucune dépendance ORT n'est manquante. Si erreur, vérifier que onnxruntime-android-qnn:1.24.3 est bien dans les deps Gradle.

  2. Charge un engine + un transcribe sur un audio fixture FR (par ex 3 s de parole continue) : le texte sorti doit être lisible et cohérent avec ce que sort WhisperHybridEngine.kt sur le même fichier. Si différence majeure (>2 tokens d'écart), capturer dans un bug et signaler — possible drift FP16 NPU acceptable mais à valider.

  3. Sanity sur null/edge : la façade Kotlin valide déjà via require() au constructeur (handle != 0L). Mais tester explicitement :

    • pcm = ShortArray(0) → err -1 ou -2
    • language = "" → utilise "fr" par défaut côté C++
    • audio < 1 s → padded à 30 s automatiquement par le mel
    • audio > 30 s → tronqué à 30 s (limite Whisper standard)
  4. VAD drop-in : comparer comportement SttVad vs VadStage.kt sur la même séquence PCM. Doit déclencher SPEECH/END_OF_SPEECH aux mêmes endroits (même seuil RMS=150, même fenêtre 100 ms).

  5. Threading : SttEngine.transcribe() est synchrone bloquant (~500 ms pour 1.6 s audio, ~2 s pour 10 s). À appeler depuis Dispatchers.IO côté Kotlin (comme l'actuel WhisperHybridEngine.transcribe).

  6. Cycle de vie : release() ferme les sessions ORT et libère les contextes QAIRT (~378 MB RAM). À appeler quand l'app sort du foreground (autrement les 378 MB restent).

Si ces 6 points passent → migration validée. Sinon → me signaler le delta.

10. Contacts & support

Code source : /opt/Kazeia-engine/dist/jni/stt_engine.{h,cpp} + kazeia_mel.{h,cpp} + kazeia_stt_jni.cpp Test CLI : /opt/Kazeia-engine/dist/jni/stt_cli.cpp Bench artifacts : /opt/Kazeia-engine/dist/b-jni/kazeia_stt_cli + libkazeia_stt.so

Pour reproduire un bug : capture logcat avec tag kazeia_stt (logs ORT exceptions y vont).