15 KiB
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, après fix DFT 400 + forced prompt SOT/lang/task) :
Cas réel Kazeia (audios ~3 s parole dense, mesuré directement sur la tablette) :
| Étape | Prod Kotlin (22 tok / 1.6 s) | C++ unifié (21 tok mesuré sur 3 s) |
|---|---|---|
| Mel | 189 ms | 220-320 ms (DFT directe N_FFT=400, obligatoire) |
| Encoder | 125 ms | 60-86 ms |
| Decoder/token | 23 ms | 12-13 ms |
| Decoder total | 506 ms (22 tok) | 252 ms (21 tok) |
| Total | ~820 ms (estimé) | ~620 ms (mesuré 21 tok / 3 s) |
| RAM peak | 545 MB | 377 MB (-31 %) |
| A/B accuracy in-app | référence | à mesurer par dev (cf §9.2) |
Honnêteté sur les chiffres : les annonces précédentes (-45 %, mel 60 ms) cumulaient un bug. Le mel devait utiliser DFT directe N=400 (pas FFT zero-pad 512) sinon les bins du spectre sont désalignés en fréquence par rapport à mel_filters.json (40 Hz/bin attendu vs 31.25 Hz/bin produit). Sur audios à hautes harmoniques (voix féminines, certaines voix), cela causait l'encoder à produire un cross-attention "no speech" → decoder sortait <EOT> immédiat → transcription vide. Bug fix v2 dans la session 01/06.
Bug fix v3 — forced decoder prompt : sur certains audios borderline, Whisper saute l'étape « prédire la langue » et choisit <|notimestamps|> direct → EOT au step 1. Solution standard HF : forcer le prompt <SOT, |lang|, |transcribe|> puis laisser le modèle décider du mode timestamps. PAS forcer <|notimestamps|> (le decoder QAIRT préfère le mode timestamps).
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 à :
- Options QNN : burst + v79 + enable_htp_fp16_precision = NPU à fréquence max
- Zero-copy :
Ort::Valuepersistants pointant sur les buffers self_k/v (pas devector::emplace_backqui 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
WhisperHybridEngineKotlin 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) :
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 :
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) :
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) :
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 :
private val engine = WhisperHybridEngine(nativeLibDir = ctx.applicationInfo.nativeLibraryDir)
engine.load(modelPath = WHISPER_DIR)
// ...
val result = engine.transcribe(audio, language)
Après :
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 :
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 :
EPContexten mode CPU pur — utiliseuseHtp=true) - vocab.json absent ou parse fail
7. Bench rapide (sur tablette via adb shell)
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) :
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 :
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 :
-
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 queonnxruntime-android-qnn:1.24.3est bien dans les deps Gradle. -
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.ktsur 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. -
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 -2language = ""→ 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)
-
VAD drop-in : comparer comportement
SttVadvsVadStage.ktsur 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). -
Threading :
SttEngine.transcribe()est synchrone bloquant (~500 ms pour 1.6 s audio, ~2 s pour 10 s). À appeler depuisDispatchers.IOcôté Kotlin (comme l'actuelWhisperHybridEngine.transcribe). -
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).