Kazeia-engine/dist/STT_INTEGRATION.md

12 KiB
Raw Blame History

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) :

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.0garde 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 : EPContext en mode CPU pur — utilise useHtp=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

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).