289 lines
12 KiB
Markdown
289 lines
12 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** (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).
|