# Rapport complet TTS Qwen3-TTS — Kazeia ## 2026-04-01 --- ## 1. Modele choisi **Qwen3-TTS-12Hz-0.6B-Base** (Alibaba/Qwen) - 0.6B parametres, 12 Hz codec (12 tokens/seconde audio) - 16 codebooks par frame audio (hierarchiques) - Architecture : Talker (28 layers, 1024 dim) + Code Predictor (5 layers, 1024 dim) + Speech Decoder (conv) - Voice cloning obligatoire via x-vector (le modele Base n'a aucune voix built-in) --- ## 2. Architecture du pipeline ``` Texte → Tokenizer → [Prefill] → Talker → CB0 token ↓ Code Predictor → CB1-CB15 ↓ 16 codebooks → VQ Decode → Audio PCM ``` ### Etapes detaillees : 1. **Prefill** (10 tokens) : `<|im_start|>assistant\n` + 4 tokens controle (think, think_bos, lang_fr, think_eos) + speaker embedding + bos + premier token texte 2. **Generation interleaved** (boucle autoregressive) : - Talker forward → logits CB0 + hidden state - Code Predictor (hidden, CB0_emb) → CB1-CB15 autoregressivement (15 steps) - Somme 16 embeddings codebooks + texte trailing → input suivant du talker - Sampling (temp=0.9, top_k=50, repetition_penalty=1.05) - Arret sur EOS (token 2150) 3. **Decodage** : VQ lookup → pre_conv → preprocessor → conv_decoder → audio 24kHz --- ## 3. Reussites ### 3.1 Pipeline fonctionnel complet - Audio de bonne qualite, voix clonee reconnaissable - EOS naturel (le modele s'arrete seul) - Fonctionne pour des phrases de longueur variable - **RTF 7.1** (28s pour 4s audio) ### 3.2 Export ONNX valide - **Talker KV-cache** : 1.77 GB, 28 layers, shapes fixes (KV=199), valide identique a PyTorch - **CP fullseq** : 420 MB, 5 layers, shapes dynamiques, causal mask, 15/15 match vs PyTorch - **CP KV-cache** : 420 MB, shapes fixes (KV=16), valide 15/15 match vs PyTorch - **Decoder** (pre_conv + preprocessor + conv_decoder) : fonctionne sur NPU via ONNX Runtime QNN EP ### 3.3 Decodeur sur NPU - pre_conv, preprocessor, conv_decoder : tous sur QNN NPU - ~3s pour decoder un chunk de 60 tokens - Pas de degradation de qualite ### 3.4 Bug critique trouve et corrige : tts_pad - **Decouverte** : apres epuisement des tokens texte, le modele Python ajoute `tts_pad_embed` (pas des zeros) - **Impact** : sans tts_pad, le modele ne converge JAMAIS vers EOS (100+ tokens sans arret) - **Correction** : une ligne changee dans la boucle de generation - C'etait LE bug qui empechait le pipeline de fonctionner correctement ### 3.5 Bug CP corrige : QK normalization - Le CP utilise RMSNorm sur Q et K avant le rotary embedding (`q_norm`, `k_norm`) - Notre premiere implementation manuelle de l'attention les oubliait → divergence totale au step 2 - Correction : ajout de `attn.q_norm()` et `attn.k_norm()` dans le wrapper ### 3.6 Export ExecuTorch .pte - CP KV-cache exporte en .pte avec QNN fp16 backend (SM8750) - **55ms pour 17 steps NPU** (vs 5.5s CPU) via le runner C++ standalone - Pipeline d'export : torch.export → to_edge_transform_and_lower_to_qnn → .pte - Contournement du bug `WrapWithSetGradEnabled` : pre-calcul des rotary cos/sin ### 3.7 JNI ExecuTorch integre dans l'app - `libexecutorch.so` (49MB) compile pour arm64 avec QNN backend - Classes Java ExecuTorch compilees dans un JAR local - Dependances fbjni + soloader resolues - **CP NPU via JNI : 79ms/step** (vs 353ms CPU = 4.5x plus rapide) --- ## 4. Echecs et limitations ### 4.1 NPU Talker (ONNX Runtime QNN EP) - **Quantification par defaut (int8/int16)** : le talker diverge apres ~10 steps, produit du bruit - **Options fp16** (`htp_precision`, `enable_htp_fp16_precision`) : aucun effet observable, probablement ignorees par le HTP backend - **Resultat** : EOS premature (1.4-2.2s au lieu de 4s) ou degeneration - **Cause** : la quantification automatique de ONNX Runtime QNN EP est trop agressive pour un modele autoregistratif ### 4.2 NPU CP (ONNX Runtime QNN EP) - Meme probleme que le talker : les codebooks secondaires sont corrompus - Cause une pause audible entre les mots (les embeddings de codebooks sont faux) - Le modele ne converge pas vers EOS (185 tokens sans arret) ### 4.3 NPU CP (ExecuTorch fp16) - Le runner standalone produit des codes en 55ms → rapide - Mais les codes sont **completement differents** du CPU (0/15 match) - L'audio genere est du bruit - **Cause** : le fp16 change suffisamment les logits pour que l'argmax donne des codebooks differents, et l'autoregression amplifie ### 4.4 CP KV-cache avec buffer fixe - Approche : KV padding a 16 positions, shift (drop oldest) a chaque step - Fonctionne sur PC (valide 15/15 vs PyTorch) - Sur tablette : degeneration au step 53+ (token 1894 x10) - **Cause** : apres 16 steps, la position 0 (hidden state du talker) est perdue du cache → le modele perd le contexte initial - **Solution adoptee** : revenir au CP fullseq (re-run la sequence complete a chaque step) ### 4.5 Subprocess NPU via su - Le NPU (Hexagon DSP) necessite root (`su`) pour l'acces - `su -c 'command'` ne transmet pas stdin/stdout au process enfant dans Java - Named pipes (FIFO) causent un deadlock (blocking open bidirectionnel) - **Solution** : JNI natif (elimine le besoin de subprocess) ### 4.6 Sampling vs Greedy - **Greedy** : le modele ne genere JAMAIS EOS (gap logit de -19 a -28 entre EOS et top codec) - **Sampling** : produit des resultats variables, mais converge vers EOS grace a la stochasticite - La repetition penalty (1.05x par token unique) n'est pas suffisante seule pour pousser vers EOS - C'est le sampling + tts_pad qui permet l'EOS naturel --- ## 5. Etat actuel du pipeline | Composant | Backend | Temps/step | Total (~50 tok) | Statut | |-----------|---------|-----------|---------|--------| | Talker (28 layers, KV-cache) | CPU fp32 | 130ms | 6.5s | ✅ Fonctionne | | CP fullseq (5 layers, seq 2→17) | CPU fp32 | 353ms | 17.7s | ✅ Fonctionne | | Decoder (VQ + conv) | NPU QNN | — | 3.0s | ✅ Fonctionne | | **Total** | | **483ms** | **~28s** | **RTF 7.1** | --- ## 6. Fichiers cles ### Sur le PC (/opt/Kazeia/) - `models_qnn/qwen3-tts-onnx/` — tous les ONNX exports - `models_qnn/cp_kv_fp16.pte` — CP ExecuTorch NPU (pret mais qualite insuffisante) - `models_qnn/cp_data/` — embeddings + rotary pour le runner - `executorch/build-android/` — libs compilees + cp_runner - `executorch/examples/qualcomm/executor_runner/cp_runner.cpp` — source du runner ### Sur la tablette (/data/local/tmp/kazeia/) - `models/qwen3-tts-npu/` — modeles ONNX + embeddings + codebooks - `models/cp_kv_fp16.pte` — CP ExecuTorch - `cp_runner` — binaire runner C++ (ARM64) - `cp_data/` — donnees statiques pour le runner ### Dans l'app (kazeia-android/) - `app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt` — moteur TTS complet - `app/src/main/jniLibs/arm64-v8a/libexecutorch.so` — JNI ExecuTorch (49MB) - `app/src/main/jniLibs/arm64-v8a/libfbjni.so` — dependance JNI - `app/src/main/jniLibs/arm64-v8a/libqnn_executorch_backend.so` — backend QNN - `app/libs/executorch.jar` — classes Java ExecuTorch --- ## 7. Pistes d'optimisation (non explorees) 1. **Quantification calibree** (use_16a8w avec donnees de calibration) — pourrait preserver la qualite sur NPU 2. **Export talker en .pte fp32** — ExecuTorch CPU pourrait etre plus rapide que ONNX Runtime 3. **Streaming decode** — decoder le premier chunk pendant la generation du deuxieme 4. **NNAPI EP** — backend GPU Adreno (pas HTP) pour le talker/CP 5. **Modele TTS plus petit** — SpeechT5 ou VITS pour un RTF < 1 au detriment de la qualite 6. **KV-cache CP correct** — augmenter CP_KV_LEN a 17 (au lieu de 16) pour ne pas perdre la position 0 --- ## 8. Lecons apprises 1. **Les modeles TTS sont beaucoup plus sensibles a la precision que les LLM** — le LLM Qwen3 tourne a 90 tok/s en int4 sur NPU, mais le TTS ne supporte meme pas le fp16 2. **L'autoregression amplifie les erreurs** — une petite erreur au step N se propage et s'amplifie aux steps N+1, N+2... 3. **Le debug embedding-level est essentiel** — sans comparer tensor par tensor avec PyTorch, impossible de trouver les bugs (tts_pad, q_norm/k_norm) 4. **ONNX Runtime QNN EP != ExecuTorch QNN** — deux stacks completement differentes avec des comportements de quantification differents 5. **Le `su` Android est un cauchemar pour l'IPC** — stdin/stdout ne passent pas, il faut du JNI natif ou des fichiers