336 lines
14 KiB
Markdown
336 lines
14 KiB
Markdown
# 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).
|