# 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) : Cas réel Kazeia (audios ~1.6 s, parole dense, **apples-to-apples par étape** vs prod) : | Étape | Prod Kotlin (22 tok) | C++ (warm, 12 tok mesuré) | Extrapol 22 tok C++ | |---|---:|---:|---:| | Mel | 189 ms | 80 ms | 80 ms | | Encoder | 125 ms | **62 ms** | 62 ms | | Decoder/token | 23 ms | **14 ms** | 14 ms | | Decoder total | 506 ms | 164 ms (12 tok) | ~308 ms | | **Total** | **820 ms** | **320 ms (12 tok)** | **~450 ms (-45 %)** | | **RAM peak** | 545 MB | **377 MB** | **-31 %** | 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) : ```kotlin 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** : ```kotlin val vad = SttVad() // 16 kHz, 100 ms frames, seuil 150, 300/800 ms val buf = mutableListOf() 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) : ```bash 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.0` — **garde 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) : ```python 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 : ```kotlin private val engine = WhisperHybridEngine(nativeLibDir = ctx.applicationInfo.nativeLibraryDir) engine.load(modelPath = WHISPER_DIR) // ... val result = engine.transcribe(audio, language) ``` Après : ```kotlin 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 : ```kotlin 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) ```bash 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) : ```cpp 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 : ```kotlin 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).