Kazeia-engine/dist/STT_INTEGRATION.md

336 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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) :
```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).