14 KiB
Intégration TTS Kazeia-Engine — Guide développeur
⚠️ BLOQUEUR SELinux découvert 02/06 — lire la section 0 avant toute intégration in-app. La lib
libkazeia_tts.soactuelle (linkelibllama.so+libggml-hexagon.so) NE FONCTIONNE PAS dans un APKuntrusted_appà cause d'une policy SELinux Android/Qualcomm qui interdit l'accès à/dev/fastrpc-cdsprequis par FastRPC. Rebuild CPU-only requis pour usage in-app. Le speaker encoder (clonage vocal) est lui CPU-only et n'est pas affecté.
Date : 02/06/2026
Cible : remplacer le pipeline TTS legacy par libkazeia_tts.so + façade Kotlin TtsEngine.kt. Inclut le clonage vocal embarqué via speaker encoder ECAPA-TDNN.
Runtime : libllama + libggml (fork ql Qualcomm) avec backend Hexagon HTP V79 — décode ggml in-process. Pas d'ONNX, pas de Python, pas de root.
Composants in-process :
- Talker Qwen3-TTS via
libllama.so(texte → codes audio 16 codebooks) - CP (Code Predictor) en ggml C++ avec KV-cache (codes → embeddings audio)
- Decoder ggml chraac intégré (embeddings → WAV PCM 24 kHz)
- Speaker encoder ECAPA-TDNN optionnel (WAV ref → x_vector[1024] pour clonage vocal)
Perf prouvée (Snapdragon 8 Elite SM8750, audio FR 2-5 s) :
- RTF total : ~2.5-3.0 selon longueur (talker 32 ms/frame, CP 70 ms/frame, decoder RTF ~2)
- Clonage vocal : encoder per-call 190-530 ms selon ref WAV (5-15 s)
- RAM peak : ~3.5 GB (talker f32 1.6 GB + CP 250 MB + decoder 325 MB + vocab 50 MB + fixtures 1.2 GB)
- Clonage ajoute ~34 MB RAM permanent au load
0. Bloqueur SELinux découvert 02/06 — à lire en premier
Symptôme : tts_engine_load crashe in-app (SIGABRT dans le backend hexagon), pas en standalone adb shell.
Cause identique au LLM : libkazeia_tts.so linke libllama.so qui dépend de libggml-hexagon.so, qui dlopen libcdsprpc.so (FastRPC). La policy SELinux interdit untrusted_app d'accéder à /dev/fastrpc-cdsp :
avc: denied { open } for path="/dev/fastrpc-cdsp"
scontext=u:r:untrusted_app tcontext=u:object_r:vendor_qdsp_device
Spécificité TTS : dans le pipeline TTS Qwen3, le talker fait son prefill sur HTP (option C). Mais en pratique, sur des prompts TTS courts (~20-30 tokens), le prefill HTP n'apporte que ~50 ms vs CPU pur. Donc le rebuild CPU-only impacte 0 perceptiblement le TTS :
| Pipeline actuel | TTS CPU-only | |
|---|---|---|
| Talker prefill | ~50 ms (HTP) | ~150 ms (CPU) |
| Talker loop (decode) | déjà CPU | déjà CPU |
| CP loop | déjà CPU | déjà CPU |
| Decoder chraac | déjà CPU | déjà CPU |
| Total tour TTS | ~8 s | ~8.1 s (+0.1 s) |
→ Coût négligeable pour TTS. Le rebuild CPU-only préserve toutes les fonctionnalités y compris le clonage vocal embarqué (le speaker encoder ECAPA-TDNN est CPU-only de toute façon, jamais touché par cette histoire HTP/SELinux).
Solution : même rebuild CPU-only (-DGGML_HEXAGON=OFF) que pour LLM. Une seule chaîne de libs partagée. Voir project_engine_selinux_fastrpc_blocker en mémoire.
État jusqu'à rebuild : flag tts_engine=lib arme la bascule mais la lib ne fonctionnera pas in-app. Garder tts_engine=prod jusqu'à rebuild CPU-only des libs.
1. Ce que la lib fait
libkazeia_tts.so (929 KB, SHARED, dépend de libllama + libggml*)
├── TtsEngine load(talker_gguf, vocab_gguf, dump_dir, useHtp, nThreads, cpUseCache)
├── TtsEngine synthesize(text, outWavPath, maxSteps, seed, sampling, xvectorOverride?)
├── TtsEngine encode_speaker_wav(refWavPath) -> FloatArray[1024] // clonage
├── TtsEngine encode_speaker_waveform(pcm, sampleRate) // clonage in-memory
└── TtsEngine free(handle)
API Kotlin (TtsEngine.kt) :
val tts = TtsEngine(
talkerGguf = "/data/.../talker_f32.gguf",
vocabGguf = "/data/.../Qwen3-4B-Q4_0.gguf",
dumpDir = "/data/.../tts_dump",
useHtp = false, // true -> HTP0 prefill option C ; false -> CPU pur
nThreads = 6,
cpUseCache = true, // KV cache CP (gain RTF -39%)
)
// Synthèse classique (voix par défaut = damien_xvector.bin fixture)
val r = tts.synthesize(
text = "Bonjour, comment allez-vous ?",
outWavPath = "/sdcard/out.wav",
maxSteps = 256,
sampling = TtsSampling() // top_p=0.95, rep_penalty=1.10 validés
)
println("WAV ${r.frames} frames, ${r.audioMs} ms audio en ${r.totalMs} ms, RTF=${r.totalMs.toFloat()/r.audioMs}")
// Synthèse avec voix clonée
val xvec = tts.encodeSpeakerWav("/sdcard/voix_pierre.wav") // 1024 floats
val r2 = tts.synthesize(text = "...", outWavPath = "...", xvectorOverride = xvec)
tts.release()
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_tts.sh
# produit b-jni/libkazeia_tts.so
b. Libs à copier dans app/src/main/jniLibs/arm64-v8a/
8 fichiers obligatoires (vérifiés via readelf -d sur la lib produite) :
| Fichier | Taille | Origine | Rôle |
|---|---|---|---|
libkazeia_tts.so |
929 KB | dist/b-jni/ |
la lib elle-même |
libllama.so |
35 MB | dist/lib/ |
fork ggml-org/llama.cpp + Hexagon |
libggml.so |
627 KB | dist/lib/ |
ggml core |
libggml-base.so |
6.6 MB | dist/lib/ |
ggml base ops |
libggml-cpu.so |
4.7 MB | dist/lib/ |
ggml CPU backend (NEON ARM v8.6) |
libggml-hexagon.so |
3.3 MB | dist/lib/ |
ggml Hexagon HTP backend (host wrapper) |
libggml-htp-v79.so |
354 KB | dist/lib/ |
DSP skel V79 (chargé via dlopen runtime par libggml-hexagon) |
libc++_shared.so |
1.8 MB | dist/lib/ |
NDK r27d STL (peut être déjà fournie par votre projet) |
Total : ~52 MB. Important côté APK mais indispensable. Pas de Maven AAR alternatif — il faut pousser ces .so.
Crash typique si une lib manque (vu en bench) :
dlopen failed: library "libllama.so" not found: needed by /data/app/.../libkazeia_tts.so
→ Vérifier que les 8 libs sont bien dans jniLibs/arm64-v8a/.
c. Copier la façade Kotlin
Copie dist/jni/TtsEngine.kt dans app/src/main/java/com/kazeia/tts/TtsEngine.kt. Package = com.kazeia.tts (cohérent avec l'existant).
d. Modèles sur la tablette
Inchangé par rapport au pipeline TTS actuel. Push dans le dir habituel :
/data/local/tmp/kazeia/tts/
├── talker_f32.gguf # Qwen3-TTS talker 1.6 GB
├── tts_dump/
│ ├── cp_f16.gguf # CP weights
│ ├── cp_heads.bin
│ ├── cp_codec_embs.bin
│ ├── qwen3tts_decoder.gguf # decoder ggml chraac
│ ├── text_embed.bin # ~1.2 GB
│ ├── tp_fc1_w.bin / tp_fc1_b.bin
│ ├── tp_fc2_w.bin / tp_fc2_b.bin
│ ├── talker_tok_embd.bin # tied embed reference
│ ├── damien_xvector.bin # voix par défaut (1024 floats)
│ ├── manifest_text.txt # constantes BOS/EOS/PAD
│ ├── speaker_encoder.gguf # OPTIONNEL : 17 MB clonage vocal
│ └── mel_basis_qwen3tts.bin # OPTIONNEL : 256 KB librosa mel
└── (vocab_gguf = un Qwen3-4B-Q4_0.gguf pour le tokenizer texte)
3. Migration depuis le pipeline TTS legacy
Si tu avais tts_talker_cpu.cpp / tts_cp_cpu.cpp compilés en source dans le projet app contre un ancien fork ggml :
- Tu peux les supprimer. Tout ce code est embarqué dans
libkazeia_tts.so(tts_engine.cpp + cp_inference.cpp + speaker_encoder.cpp + sampler.cpp + kazeia_text_tokenizer.cpp + chraac decoder statique). - Tu n'as plus besoin de recompiler quoi que ce soit contre les headers du fork ql — la lib est self-contained.
- Tu n'as plus besoin du tokenizer texte custom — il est dans la lib via
kazeia_text_tokenizer.cppqui charge le vocab d'un GGUF Qwen.
4. Exemples d'usage
Synthèse simple
val tts = TtsEngine(
talkerGguf = "$kazeiaDir/talker_f32.gguf",
vocabGguf = "$kazeiaDir/Qwen3-4B-Q4_0.gguf",
dumpDir = "$kazeiaDir/tts_dump"
)
val r = tts.synthesize("Bonjour Pierre, comment vous sentez-vous aujourd'hui ?",
outWavPath = "$cacheDir/reply.wav")
playWav(r.outWavPath)
tts.release()
Clonage vocal — encode 1 fois, synth N fois
val tts = TtsEngine(/* ... */, speakerEncoderGguf = "$kazeiaDir/tts_dump/speaker_encoder.gguf",
mel_basis_path = "$kazeiaDir/tts_dump/mel_basis_qwen3tts.bin")
// Encode une fois (cache l'utiliser sur les N synthèses qui suivent)
val pierreVoice = tts.encodeSpeakerWav("$voicesDir/pierre_5s.wav") // ~190 ms
for (text in messages) {
val r = tts.synthesize(text, outWavPath = nextWav(), xvectorOverride = pierreVoice)
playWav(r.outWavPath)
}
tts.release()
Sampling tuning
Les défauts (top_p=0.95, rep_penalty=1.10) sont validés à l'écoute sur panel FR (cf mémoire project_tts_session_28may). Override possible :
val r = tts.synthesize(text, outWavPath, sampling = TtsSampling(
cpTemp = 0.9f, cpTopK = 50, cpTopP = 0.95f, cpRepPenalty = 1.10f,
talkerTemp = 0.9f, talkerTopK = 50, talkerTopP = 0.95f, talkerRepPenalty = 1.10f
))
5. Codes d'erreur (TtsResult.err)
| Code | Signification |
|---|---|
| 0 | OK |
| -1 | engine nul ou texte/outWavPath nul |
| -2 | pas de tokenizer chargé (vocabGguf manquant au load) |
| -3 | tokens trop courts (texte < 8 tokens BPE) |
| -4 | prefill talker FAIL (modèle, vocab, manifest) |
| -6 | WAV write FAIL |
Au tts_engine_load (renvoie null → exception Kotlin IllegalStateException) :
- talker_gguf ou dump_dir nul
- fichiers fixtures manquants (
text_embed.bin,tp_*,tok_embd.bin,damien_xvector.bin) - vocab GGUF invalide
- modèle talker invalide
6. Bench rapide standalone (avant d'intégrer)
Pour vérifier que la lib + libs sont OK avant câblage app :
adb push dist/b-jni/tts_pipeline /data/local/tmp/kz-engine/bin/
adb push dist/lib/lib*.so /data/local/tmp/kz-engine/bin/
adb shell '
cd /data/local/tmp/kz-engine
export LD_LIBRARY_PATH=./bin
export ADSP_LIBRARY_PATH=./bin
export KZTTS_TEXT="Bonjour, comment allez-vous ?"
export KZTTS_VOCAB_GGUF=Qwen3-4B-Q4_0.gguf
./bin/tts_pipeline talker_f32.gguf tts_dump /tmp/out.wav cpu 64
'
Sortie typique :
tts_engine_load: 1.0s (talker_n_embd=1024, npe=4, kz_tok=1, cp_cache=1, spk_enc=0)
=== TTS : N=42 frames (audio 3.50s) en 8.5s (RTF 2.43) ===
WAV -> /tmp/out.wav
Si la lib charge mais le synth échoue, lire la sortie stderr (codes d'erreur ci-dessus).
7. Checklist d'intégration (à valider en premier in-app)
-
System.loadLibrary("kazeia_tts")réussit au démarrage de l'app. Si erreurlibrary "libllama.so" not found, vérifier qu'il y a bien les 8 libs dansjniLibs/arm64-v8a/. -
Charge un engine + 1 synthèse sur un texte fixture FR (« Bonjour. ») : doit produire un WAV audible. Si silence ou bruit, vérifier que
tts_dump/est complet (notammenttext_embed.bin1.2 GB ettp_fc1/2_*qui sont obligatoires). -
Sanity null/edge :
- text vide → err -1
- text 3 caractères → err -3 (trop court)
- outWavPath dans un dir inexistant → err -6
-
Threading :
TtsEngine.synthesize()est synchrone, ~3-10 s selon longueur. Appeler depuisDispatchers.IO. Le n_threads est paramétrable au load (défaut 6). -
Cycle de vie :
release()à appeler quand l'app sort du foreground (3.5 GB de RAM libérée). -
Clonage vocal (optionnel) : si tu fournis
speakerEncoderGguf+melBasisPathau load, tu peux appelerencodeSpeakerWav()ensuite. Sinon, l'engine utilise ledamien_xvector.binfixture (voix damien).
8. Pièges connus
-
Stack TTS legacy : si tu avais des sources
tts_talker_cpu.cpp/tts_cp_cpu.cppcompilées contre un fork ggml différent, tu peux et dois les supprimer. Tout est dans la lib. -
chraac decoder : le decoder ggml chraac est intégré statiquement dans
libkazeia_tts.so. Pas besoin de pousserlibqwen3tts-decoder.aséparément, ni le binairedecoder_subprocess(l'ancien pattern subprocess est obsolète). -
HTP TTS :
useHtp = truen'apporte rien sur le talker actuel (decode CPU 16.5 tok/s reste meilleur que HTP 3.6 tok/s pour les modèles dense). GarderuseHtp = falsesauf si tu testes le prefill HTP gain. -
Sample rate : le clonage vocal
encodeSpeakerWav()attend du mono 16-bit 24 kHz. Si tu lis un WAV à un autre SR, resample externe (libAndroid AudioRecord + resampler ou ffmpeg).
9. Perf détaillée
Pour 3 s d'audio synthétisé, 36 frames (12 frames/s = 80 ms/frame) :
prefill ~150 ms (talker prompt -> KV initial)
talker_loop ~1150 ms (36 frames × 32 ms)
cp_loop ~2520 ms (36 frames × 70 ms)
decoder ~4400 ms (RTF ~1.3 sur 36 frames)
─────────────────────────
total ~8.2 s pour 3 s audio = RTF 2.7
Le decoder (chraac) est le pic. Optimisations futures : décoder par chunks de 8 frames pour overlap, ou portage HTP (R&D ouverte).
Clonage : +190-530 ms one-time par changement de voix (encode_speaker_wav), 0 ms si on garde la même voix sur plusieurs synthèses.
10. Contacts & support
Code source : /opt/Kazeia-engine/dist/jni/tts_engine.{h,cpp} + cp_inference.cpp + speaker_encoder.cpp + kazeia_mel.{h,cpp} + sampler.{h,cpp} + kazeia_tts_jni.cpp.
Test CLI : /opt/Kazeia-engine/dist/jni/tts_pipeline.cpp (binaire tts_pipeline).
Bench artifacts : /opt/Kazeia-engine/dist/b-jni/libkazeia_tts.so + tts_pipeline.
Pour reproduire un bug : capture logcat avec tag tts_engine.