95 lines
7.3 KiB
Markdown
95 lines
7.3 KiB
Markdown
# Intégration CosyVoice3 distillé — Guide développeur Kazeia
|
||
|
||
**Date** : 14/06/2026
|
||
**Cible** : moteur TTS clonage vocal zero-shot, **9 langues** (fr, en, de, es, it, ru, zh, ja, ko), tournant **100 % CPU sur la tablette** (SM8750 / V79).
|
||
**Statut** : pipeline E2E validé sur tablette, JNI livrée. **Pas de bloqueur SELinux** (CPU-only, aucune dépendance FastRPC/hexagon).
|
||
|
||
> Pour le **portugais, l'arabe** et 14 autres langues hors CosyVoice3 → moteur **Chatterbox** (à intégrer, dispatch par langue côté app).
|
||
|
||
---
|
||
|
||
## 1. Architecture
|
||
|
||
CosyVoice3-0.5B = LLM (Qwen2.5-0.5B + tête tokens audio) → flow-matching (DiT) → vocoder HiFT. **Le flow a été distillé sur A40 (Reflow 2-NFE)** : 10 étapes+CFG → **2 étapes sans CFG**, ÷10 de calcul, qualité teacher préservée (ECAPA 0,878 vs voix de référence).
|
||
|
||
Base = fork de [Lourdle/cosyvoice.cpp](https://github.com/Lourdle/cosyvoice.cpp) (ggml C++, MIT) avec patch reflow (schedule linéaire + CFG off + N pas, pilotés par env `CV_REFLOW`/`CV_NFE`, lus par le loader).
|
||
|
||
## 2. Libs à pousser dans `jniLibs/arm64-v8a/` — **4 fichiers seulement**
|
||
|
||
| Fichier | Rôle |
|
||
|---|---|
|
||
| `libkazeia_cosyvoice.so` | bridge JNI (5 symboles `com.kazeia.tts.CosyVoiceJni`) |
|
||
| `libcosyvoice.so` | moteur **auto-contenu** (LLM+flow+HiFT, **ggml statique embarqué**) |
|
||
| `libomp.so` | OpenMP (NDK r27d) |
|
||
| `libc++_shared.so` | STL (NDK r27d) |
|
||
|
||
**⚠ PAS de `libggml*.so`** — c'est volontaire et critique. ggml est compilé **en statique dans `libcosyvoice.so` avec visibilité masquée** (`--exclude-libs,ALL`) : `libcosyvoice.so` exporte **0 symbole `ggml_*`** (100 symboles `cosyvoice_*` uniquement). Cela **évite la collision de SONAME** `libggml.so/-base/-cpu` avec le stack LLM/TTS gelé de l'app (fork ql) — les deux moteurs ont des builds ggml différents, et les charger ensemble en `RTLD_GLOBAL` provoquerait une interposition ELF (crash/corruption). Ici, le ggml de CosyVoice est invisible au namespace global → zéro conflit. Vérifié : `readelf -d libkazeia_cosyvoice.so` → NEEDED = **libcosyvoice + libc++_shared + système** ; `nm -D libcosyvoice.so | grep ggml_` → vide.
|
||
|
||
**Aucune dépendance `libggml-hexagon` / FastRPC** non plus → pas de SIGABRT `untrusted_app`.
|
||
|
||
## 3. Modèle + voix (assets)
|
||
|
||
- **Modèle** : `cv3_distilled_30k_hiftf16.gguf` (~953 Mo, flow q8_0 distillé 2-NFE + **HiFT en F16** = vocodeur −28 % sur ARM) — livré séparément. À placer dans le storage app. (Remplace l'ancien `cv3_distilled_30k.gguf` ; même API, juste plus rapide.)
|
||
- **Voix** : artefacts `.cvps` (GGUF 4 tenseurs : mel prompt + x-vector + tokens + transcription). **Générés OFFLINE** (enrôlement) via `cv_make_prompt.py` à partir de la phrase de consentement (~15 s) + sa transcription. Exemple fourni : `damien.cvps.example`. Une voix = un `.cvps` shippé/téléchargé. Enrôlement cross-lingual : une voix sert pour les 9 langues.
|
||
|
||
```bash
|
||
# enrôlement offline (poste de prépa, pas la tablette)
|
||
python cv_make_prompt.py voix_consentement.wav transcription.txt sortie.cvps
|
||
```
|
||
|
||
## 4. Façade Kotlin (`CosyVoiceTtsEngine.kt`)
|
||
|
||
```kotlin
|
||
val tts = CosyVoiceTtsEngine("$dir/cv3_distilled_30k_hiftf16.gguf", nfe = 2, nThreads = 8)
|
||
val damien = tts.loadVoice("$dir/damien.cvps")
|
||
val pcm: FloatArray = tts.synthesize(damien, "Bonjour, comment allez-vous ?") // 24 kHz mono [-1,1]
|
||
// ... jouer pcm via AudioTrack (ENCODING_PCM_FLOAT, 24000) ...
|
||
damien.release(); tts.release()
|
||
```
|
||
|
||
Interface : `nativeLoad(gguf, nfe, nThreads)` · `nativeLoadVoice(handle, cvps)` · `nativeSynthesize(voice, text, speed)→FloatArray` · `nativeFreeVoice` · `nativeFree`.
|
||
|
||
## 5. Flag de dispatch (menu Kazeia)
|
||
|
||
Même pattern que `sttEngine`. Aiguillage par **langue** :
|
||
|
||
```kotlin
|
||
val ttsEngine = when {
|
||
lang in COSYVOICE_LANGS /* fr,en,de,es,it,ru,zh,ja,ko */ -> cosyVoiceEngine
|
||
else -> chatterboxEngine // pt,ar,he,hi,tr,pl,nl,...
|
||
}
|
||
```
|
||
|
||
## 6. Performance (SM8750 / V79, mesuré) — **streaming chunk-causal + cache K/V prompt**
|
||
|
||
Le moteur tourne en mode **streaming chunk-causal** avec un **cache K/V de prompt persistant** (activé automatiquement par le JNI). Le prompt (la voix, ~70 % du calcul du flow) n'est calculé **qu'une fois par voix**, puis réutilisé sur tous les énoncés suivants.
|
||
|
||
| Tour | LLM | flow | HiFT | tour complet | RTF (énoncé ~3,8 s) |
|
||
|---|---:|---:|---:|---:|---:|
|
||
| **1er** (voix non encore vue) | 1,0 s | 5,1 s | 1,2 s | 7,3 s | **~1,9** |
|
||
| **2e+** (même voix, prompt caché) | 0,9 s | **1,2 s** | 1,2 s | 3,35 s | **0,88** |
|
||
|
||
- **÷4,1 sur le flow** (cache K/V prompt) + **HiFT F16 (−28 %)** → **RTF 0,88 soutenu** sur V79, sous le temps réel (validé on-device 15/06). Étages équilibrés (~0,25-0,32 chacun), plus de goulot dominant. Réponses courtes (cas thérapeutique) : encore mieux.
|
||
- **Transparent pour l'app** : il suffit de **réutiliser le même moteur + la même voix** entre les tours (ce que fait déjà le pipeline). 1ère synthèse d'une voix = lente (remplit le cache), les suivantes = rapides. Le cache est par-voix (clé = identité du `.cvps`) ; changer de voix re-remplit.
|
||
- Qualité préservée : sortie cache ≈ sortie non-cachée (mel corr 0,9997 ; ECAPA/campplus ~0,80-0,88, monte avec la longueur du prompt).
|
||
- RAM ~1 Go (modèle) + ~30 Mo/voix + **~270 Mo de cache K/V** (la 1ère voix).
|
||
- **1er énoncé reste à RTF ~2** (le prompt s'y calcule) : si la latence du tout premier tour gêne, un chantier *first-audio* (chunks + HiFT streaming) est possible — pas nécessaire pour le temps-réel soutenu.
|
||
- Génère phrase par phrase (split activé) ; lire en streaming via AudioTrack.
|
||
|
||
## 7. Checklist intégration
|
||
|
||
1. Pousser les **4 libs** dans `jniLibs/arm64-v8a/` (`libkazeia_cosyvoice.so`, `libcosyvoice.so`, `libomp.so`, `libc++_shared.so`). **Pas de `libggml*.so`** (embarqué statiquement → pas de collision avec le stack LLM/TTS existant).
|
||
2. Copier `CosyVoiceTtsEngine.kt`.
|
||
3. Déployer `cv3_distilled_30k_hiftf16.gguf` + les `.cvps` enrôlés dans le storage.
|
||
4. `System.loadLibrary("kazeia_cosyvoice")` + 1 synthèse de test (vérifier PCM non vide).
|
||
5. Câbler le flag `ttsEngine` (dispatch par langue cosyvoice/chatterbox).
|
||
6. AudioTrack en `ENCODING_PCM_FLOAT` @ 24000, lecture par phrase (streaming).
|
||
|
||
## 8. Validation effectuée (on-device, SM8750)
|
||
|
||
- ✅ Chaîne de liens (libs **streaming-cache** 15/06) : `libkazeia_cosyvoice.so` charge + ses 5 symboles JNI résolvent contre le nouveau `libcosyvoice.so` sur V79 (« CHAINE DE LIENS COMPLETE OK »), 0 export `ggml_`.
|
||
- ✅ **Streaming-cache validé on-device (V79, cli E2E)** : flow tour 1 (MISS) 5,1 s → tour 2+ (HIT) 1,2 s = ÷4,1, RTF tour complet 2,0 → 1,0. Qualité campplus HIT 0,80 ≈ MISS 0,82. Mel cache vs non-cache corr 0,9997.
|
||
- ✅ Chemin d'intégration E2E (harness natif répliquant la séquence JNI exacte) : load modèle → load voix → `tts_zero_shot` → PCM produit. Validé.
|
||
- ⚠ Le marshalling JNI lui-même (JNIEnv string/array) suit le pattern standard mais sera testé pour la 1ʳᵉ fois sous vraie JVM à l'intégration APK.
|
||
- **3 bugs JNI corrigés lors de cette validation** : (a) `-static-libstdc++` → `bad_cast` (deux runtimes C++) → lié en `libc++_shared` partagé ; (b) `cosyvoice_init_backend_from_path()` manquant avant load → ajouté ; (c) normalisation texte ICU absente en build NO_ICU → désactivée + split activé dans la façade.
|