Kazeia-engine/dist/TTS_INTEGRATION.md

323 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.so` actuelle (linke `libllama.so` + `libggml-hexagon.so`) **NE FONCTIONNE PAS**
> dans un APK `untrusted_app` à cause d'une policy SELinux Android/Qualcomm qui interdit
> l'accès à `/dev/fastrpc-cdsp` requis 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`) :
```kotlin
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) :
```bash
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.cpp` qui charge le vocab d'un GGUF Qwen.
---
## 4. Exemples d'usage
### Synthèse simple
```kotlin
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
```kotlin
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 :
```kotlin
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 :
```bash
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)
1. **`System.loadLibrary("kazeia_tts")` réussit** au démarrage de l'app. Si erreur `library "libllama.so" not found`, vérifier qu'il y a bien les 8 libs dans `jniLibs/arm64-v8a/`.
2. **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 (notamment `text_embed.bin` 1.2 GB et `tp_fc1/2_*` qui sont obligatoires).
3. **Sanity null/edge** :
- text vide err -1
- text 3 caractères err -3 (trop court)
- outWavPath dans un dir inexistant err -6
4. **Threading** : `TtsEngine.synthesize()` est synchrone, ~3-10 s selon longueur. Appeler depuis `Dispatchers.IO`. Le n_threads est paramétrable au load (défaut 6).
5. **Cycle de vie** : `release()` à appeler quand l'app sort du foreground (3.5 GB de RAM libérée).
6. **Clonage vocal (optionnel)** : si tu fournis `speakerEncoderGguf` + `melBasisPath` au load, tu peux appeler `encodeSpeakerWav()` ensuite. Sinon, l'engine utilise le `damien_xvector.bin` fixture (voix damien).
---
## 8. Pièges connus
- **Stack TTS legacy** : si tu avais des sources `tts_talker_cpu.cpp` / `tts_cp_cpu.cpp` compilé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 pousser `libqwen3tts-decoder.a` séparément, ni le binaire `decoder_subprocess` (l'ancien pattern subprocess est obsolète).
- **HTP TTS** : `useHtp = true` n'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). Garder `useHtp = false` sauf 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`.