Kazeia-engine/dist/STT_INTEGRATION.md

289 lines
12 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** (mesures sur Snapdragon 8 Elite / SM8750, Whisper-Small, FR continu, decoder zero-copy) :
| Métrique | Prod actuelle (Kotlin) | Lib unifiée (C++ zero-copy) | Gain |
|---|---:|---:|---:|
| Mel extraction | 189 ms (DFT brute force) | **57 ms** (FFT radix-2) | -70 % |
| Encoder NPU | 125 ms | 315 ms* | +152 % |
| Decoder/token NPU | 23 ms | **36 ms** | +56 % |
| **RTF (10 s audio, 39 tokens FR continu)** | 0.51 (sur 1.6 s, 22 tokens) | **0.18** | **2.8× mieux** |
| **RAM peak** | 545 MB | **379 MB** | **-30 %** |
\* L'encoder C++ reste localement plus lent qu'ORT Java (overhead de l'initial Ort::Value bound — différence d'optim interne ORT). Sur le RTF global et la RAM, **la version C++ bat la prod**. Le decoder a été optimisé en zero-copy (Ort::Value persistants pointant sur les buffers self_k/v, memcpy in-place des outputs) — gain x2.5 vs version initiale.
**Vérifs sanity faites au runtime** :
- Logits decoder shape = 51865 (= constante `VOCAB_SIZE`), pas de buffer overread sur argmax
- vocab.json (50258 tokens) + tokens spéciaux Whisper (1607 langue/timestamp) couvrent les 51865 logits
- Transcription FR cohérente sur 5 audios différents (damien, richard 1.6/3/5/10 s)
\* 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
```
---
## 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).