chore: dégraissage du dépôt — suivi restreint à l'app mobile (liste blanche)
Le working tree contenait ~21 k fichiers non suivis (modèles, backups, SDK, venvs, arbres expérimentaux, intouchables beta_kazeia/root_oneplus, Kaz de Damien…) qui polluaient `git status`. Remplace la blacklist fuyante par une LISTE BLANCHE : on n'ignore plus au cas par cas, on ignore tout à la racine sauf l'app mobile (kazeia-android), scripts/, executorch-*, et les docs *.md/ *.txt. Rien n'est supprimé du disque — juste sorti du suivi. Résultat : non-suivis 21389 → 0. Ajoute au passage des fichiers légitimes qui traînaient non suivis (FROZEN.md = contrat de stack figée, scripts d'export TTS, rapport). Le moteur unifié reste un dépôt séparé (/opt/Kazeia-engine). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6f45a75197
commit
4498e5ac7a
|
|
@ -1,8 +1,24 @@
|
||||||
# ============================================
|
# ============================================================
|
||||||
# Kazeia .gitignore — code only, no binaries
|
# Kazeia .gitignore — LISTE BLANCHE
|
||||||
# ============================================
|
# On ne suit QUE l'app mobile + le code/les docs essentiels.
|
||||||
|
# Tout le reste à la racine (modèles, backups, SDK, venvs, arbres
|
||||||
|
# expérimentaux, intouchables beta_kazeia/root_oneplus, Kaz, etc.)
|
||||||
|
# est ignoré — présent sur le disque, jamais supprimé, juste hors suivi.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
# === Large binary files ===
|
# 1) Ignorer tout ce qui est à la racine du dépôt...
|
||||||
|
/*
|
||||||
|
|
||||||
|
# 2) ...sauf ce qu'on veut explicitement suivre :
|
||||||
|
!/.gitignore
|
||||||
|
!/kazeia-android/
|
||||||
|
!/scripts/
|
||||||
|
!/executorch-custom/
|
||||||
|
!/executorch-patches/
|
||||||
|
!/*.md
|
||||||
|
!/*.txt
|
||||||
|
|
||||||
|
# 3) Binaires & sorties de build (même à l'intérieur des dossiers suivis) :
|
||||||
*.so
|
*.so
|
||||||
*.so.*
|
*.so.*
|
||||||
*.so.bak
|
*.so.bak
|
||||||
|
|
@ -15,8 +31,8 @@
|
||||||
*.npy
|
*.npy
|
||||||
*.wav
|
*.wav
|
||||||
*.apk
|
*.apk
|
||||||
|
*.pyc
|
||||||
# === Build outputs ===
|
__pycache__/
|
||||||
kazeia-android/app/build/
|
kazeia-android/app/build/
|
||||||
kazeia-android/build/
|
kazeia-android/build/
|
||||||
kazeia-android/.gradle/
|
kazeia-android/.gradle/
|
||||||
|
|
@ -25,61 +41,11 @@ kazeia-android/extracted/
|
||||||
kazeia-android/app/.cxx/
|
kazeia-android/app/.cxx/
|
||||||
kazeia-android/unityLibrary/
|
kazeia-android/unityLibrary/
|
||||||
|
|
||||||
# === Python environments ===
|
# 4) IDE / OS / temporaires
|
||||||
et_venv/
|
|
||||||
qnn_venv/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
# === SDKs and toolchains (external) ===
|
|
||||||
android-ndk-r27d/
|
|
||||||
qnn_sdk_242/
|
|
||||||
executorch/
|
|
||||||
|
|
||||||
# === Models and data (too large) ===
|
|
||||||
models_qnn/
|
|
||||||
tablet_backup*/
|
|
||||||
backup_*/
|
|
||||||
voix/
|
|
||||||
|
|
||||||
# === IDE ===
|
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# === Compiled binaries ===
|
|
||||||
cp_et_test_client
|
|
||||||
llama.cpp/build*/
|
|
||||||
|
|
||||||
# === OS files ===
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# === Temporary ===
|
|
||||||
*.tmp
|
*.tmp
|
||||||
*.log
|
*.log
|
||||||
*.kate-swp
|
*.kate-swp
|
||||||
|
|
||||||
# === External repos (submodules or separate) ===
|
|
||||||
Vulkan-Headers/
|
|
||||||
llama.cpp/
|
|
||||||
whisper.cpp/
|
|
||||||
kazeia-unity/
|
|
||||||
models_hf/
|
|
||||||
|
|
||||||
# === Old/misc at root ===
|
|
||||||
android-sdk/
|
|
||||||
avatar_disabled_backup/
|
|
||||||
beta_kazeia/
|
|
||||||
build_et_jar/
|
|
||||||
root_oneplus/
|
|
||||||
cmdtools.zip
|
|
||||||
forward.dlc
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# === Root-level scripts (moved to scripts/) ===
|
|
||||||
export_decoder_onnx.py
|
|
||||||
export_talker_onnx.py
|
|
||||||
extract_tts_embeddings.py
|
|
||||||
extract_vq_individual.py
|
|
||||||
generate_tokens_for_tablet.py
|
|
||||||
generate_tts_wav.py
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
# Kazeia — Stack production figée
|
||||||
|
|
||||||
|
**Date du gel : 2026-05-02**
|
||||||
|
|
||||||
|
Ce document définit la stack **autorisée** de Kazeia. Tout composant non listé
|
||||||
|
ici doit être considéré comme **non autorisé** et ne doit pas être réintroduit
|
||||||
|
sans validation explicite + mise à jour de ce contrat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Emplacement des modèles (MAJ 2026-05-18 — chantier installeur Phase 0)
|
||||||
|
|
||||||
|
Les chemins `/data/local/tmp/kazeia/models/` et `/data/local/tmp/kazeia-et/`
|
||||||
|
cités ci-dessous restent **valides sur les tablettes de dev** (modèles poussés
|
||||||
|
par `adb push`). Ils sont désormais **résolus au runtime par `KazeiaPaths`** :
|
||||||
|
|
||||||
|
- si la racine `/data/local/tmp` existe et est non vide → elle prime (dev) ;
|
||||||
|
- sinon → stockage externe app `getExternalFilesDir("kazeia")/{models,llm}`,
|
||||||
|
rempli par l'installeur (modèles téléchargés depuis le Nextcloud).
|
||||||
|
|
||||||
|
Aucun composant n'est ajouté ni retiré — seul l'emplacement de chargement
|
||||||
|
devient dynamique. `KazeiaApplication.MODELS_DIR` / `.LLM_DIR` sont les points
|
||||||
|
d'accès uniques. Cf `project_kazeia_installer_updates`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STT — Speech-to-Text
|
||||||
|
|
||||||
|
### Composant figé
|
||||||
|
|
||||||
|
`com.kazeia.stt.WhisperHybridEngine` (542 lignes) — chemin **NPU only**
|
||||||
|
- Encoder + Decoder Qualcomm AI Hub HfWhisper KV-cache (Whisper-Small 12L/12H)
|
||||||
|
- Modèles tablette : `/data/local/tmp/kazeia/models/whisper-small-sm8750/`
|
||||||
|
(fingerprints MD5 dans `/opt/Kazeia/kazeia-stt-perf-validated/models/README.md`)
|
||||||
|
- Backend : ONNX Runtime QNN HTP V79 (Snapdragon 8 Elite)
|
||||||
|
- Performance validée : RTF 0.5 (audio 1.6 s → 825 ms)
|
||||||
|
- Override `<|translate|>` → `<|transcribe|>` actif (force transcription FR)
|
||||||
|
- Auto-détection layers/heads via `enc.outputInfo["k_cache_cross_0"]`
|
||||||
|
|
||||||
|
### Mel extractor figé
|
||||||
|
|
||||||
|
`com.kazeia.stt.MelExtractor` + `kazeia-android/app/src/main/jni/mel_extractor.cpp`
|
||||||
|
- HuggingFace-compatible WhisperFeatureExtractor en C++ pur
|
||||||
|
- 80 mels × 3000 frames, FFT 400, hop 160
|
||||||
|
|
||||||
|
### Wrapper v2 figé
|
||||||
|
|
||||||
|
`com.kazeia.v2.SttStage` — thin wrapper sur `WhisperHybridEngine`
|
||||||
|
|
||||||
|
### ❌ Interdit
|
||||||
|
- Pas de fallback CPU (`WhisperSttEngine`, `WhisperJni`, `whisper.cpp`) — supprimés
|
||||||
|
- Pas de fallback Android stock (`AndroidSttEngine`) — supprimé
|
||||||
|
- Pas d'autres backends (`WhisperLiteRtEngine`, `WhisperNpuSttEngine`) — supprimés
|
||||||
|
- Pas de Qwen3-ASR (1.7B GGUF cassé, 0.6B qualité FR insuffisante)
|
||||||
|
- Pas de `libwhisper.so` dans jniLibs — supprimé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VAD — Voice Activity Detection
|
||||||
|
|
||||||
|
### Composant figé
|
||||||
|
|
||||||
|
**Détection RMS-énergie inline** :
|
||||||
|
- v1 : `KazeiaService.startContinuousListening()` — frame 1600 samples (100 ms),
|
||||||
|
threshold RMS=150, ≥ 300 ms speech, ≥ 800 ms silence end
|
||||||
|
- v2 : `com.kazeia.v2.VadStage` — réimplémentation propre du même algorithme
|
||||||
|
|
||||||
|
### ❌ Interdit
|
||||||
|
- Pas de Silero VAD (`SileroVadEngine.kt` + `silero_vad.onnx` — supprimés)
|
||||||
|
- Pas de `AudioCaptureManager.kt` — supprimé
|
||||||
|
- Pas de `core.VadEngine` interface — supprimée
|
||||||
|
- Cf `feedback_kazeia_vad_rms.md` : Silero plafonne à 5e-4 sur ce mic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLM — Large Language Model
|
||||||
|
|
||||||
|
### Composants figés v1 (legacy `KazeiaService`)
|
||||||
|
|
||||||
|
`com.kazeia.llm.ExecuTorchLlmEngine` (in-process)
|
||||||
|
- Modèle : `hybrid_llama_qnn.pte` symlink → `hybrid_llama_qnn_4b.pte` sur tablette
|
||||||
|
- Tokenizer : `tokenizer.json`
|
||||||
|
- Backend : ExecuTorch + QNN HTP NPU (in-process via PyTorch ExecuTorch JNI)
|
||||||
|
- Path : `/data/local/tmp/kazeia-et/`
|
||||||
|
|
||||||
|
### Composants figés v2 (`KazeiaServiceV2`)
|
||||||
|
|
||||||
|
`com.kazeia.v2.LlmCascadeStage` (mode mono-Speaker — temporaire RAM-budget)
|
||||||
|
- Spawn 1× `qnn_llama_runner --daemon_mode` via `ExecutorchDaemon`
|
||||||
|
- Modèle : `hybrid_llama_qnn_4b.pte` + `tokenizer.json`
|
||||||
|
- System prompt : SYS_SPEAKER (psychologue bienveillant, 1-2 phrases FR concises)
|
||||||
|
- Le code Thinker/cascade est conservé en commentaire pour restauration future
|
||||||
|
|
||||||
|
### ❌ Interdit
|
||||||
|
- Pas de `LlamaCppLlmEngine` (purgé 2026-04-29)
|
||||||
|
- Pas de `KazeiaLlmJni` (purgé 2026-04-29)
|
||||||
|
- Pas de Genie SDK (bug GQA Qwen3)
|
||||||
|
- Pas de cascade Thinker+Speaker SIMULTANÉE en cohabitation TTS (RAM OOM, cf
|
||||||
|
cleanup 2026-05-02)
|
||||||
|
- Pas de `LLM_BACKEND` constante / `_currentLlmModel` UI selector
|
||||||
|
|
||||||
|
### Restauration cascade complète : conditionné
|
||||||
|
|
||||||
|
À envisager **uniquement après** :
|
||||||
|
- Task #240 (libllama.so contre chraac-llama récent) → -30 % RAM/perf Talker+CP
|
||||||
|
- OU partage `.ptd` entre 2× 4B → -2.4 GB RAM
|
||||||
|
- OU phased loading opportuniste (cf `feedback_kazeia_vad_rms.md` non, l'analyse
|
||||||
|
RAM dans la conversation 2026-05-02)
|
||||||
|
- En attendant, mono-Speaker reste figé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TTS — Text-to-Speech
|
||||||
|
|
||||||
|
### Composant figé
|
||||||
|
|
||||||
|
`com.kazeia.tts.Qwen3TtsEngine` — voie **ggml-cpu Talker + ggml-cpu CP + ggml C++ Decoder**
|
||||||
|
- Talker : `TtsTalkerCpuJni` linké contre `libllama.so` (in jniLibs)
|
||||||
|
- Modèle préféré : `talker_f32.gguf` (1.78 GB, fp32, élimine drift fp16)
|
||||||
|
- Fallback : `talker_f16.gguf`
|
||||||
|
- CP : `TtsCpCpuJni` linké contre `libllama.so`
|
||||||
|
- Modèle préféré : `cp_f32.gguf` (316 MB)
|
||||||
|
- Fallback : `cp_f16.gguf`
|
||||||
|
- Decoder : `libtts_decoder_ggml.so` static-linked contre **chraac-llama** ggml
|
||||||
|
(build : `kazeia-tts-decoder-ggml/build-android-static-chraac/`)
|
||||||
|
- GGUF : `qwen3tts_decoder.gguf` (340 MB)
|
||||||
|
- **RTF 0.96 mesuré 2026-05-02** (cf `kazeia-tts-perf-validated/`)
|
||||||
|
- Tokenizer texte : `Qwen3BpeTokenizer` (BPE byte-level Qwen2/Qwen3)
|
||||||
|
- Voice cloning : Damien (prefix 9 × 1024 + suffix 2 × 1024)
|
||||||
|
|
||||||
|
### API figée
|
||||||
|
|
||||||
|
- `synthesizeAndPlay(text, language)` — pour invocations one-shot
|
||||||
|
- `startStreamingSession()` / `enqueueSentence(text)` / `endStreamingSession()` —
|
||||||
|
pour streaming sentence-par-sentence (utilisé dans v2.TtsStage)
|
||||||
|
|
||||||
|
### Wrapper v2 figé
|
||||||
|
|
||||||
|
`com.kazeia.v2.TtsStage` — wrapper streaming-session sur `Qwen3TtsEngine`
|
||||||
|
|
||||||
|
### ❌ Interdit dans le chemin de chargement
|
||||||
|
|
||||||
|
- Pas de chemin `cp_kv_v2 ONNX` (ligne 630-658 de Qwen3TtsEngine, gated sur
|
||||||
|
`!useCpuPath` 2026-05-02)
|
||||||
|
- Pas de `cp_et_runner` TCP (sub-process root, gated 2026-05-02)
|
||||||
|
- Pas de `talker_kv_cpu` ONNX fp32 (gated sur `!useCpuPath`)
|
||||||
|
- Pas de Hexagon talker / CP (`hexStartRunner`, `useHexagonTalker`) — pas dans
|
||||||
|
flux production
|
||||||
|
- Pas de `phrase_embeds.bin` cache (bypassed 2026-05-02 — provoquait dégénération)
|
||||||
|
|
||||||
|
### ❌ Interdit moteurs alternatifs
|
||||||
|
- Pas de `AndroidTtsEngine` — supprimé
|
||||||
|
- Pas de `ChatterboxTtsEngine` — supprimé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audio playback
|
||||||
|
|
||||||
|
### Composant figé
|
||||||
|
|
||||||
|
`com.kazeia.audio.AudioPlaybackManager` (54 lignes)
|
||||||
|
|
||||||
|
L'AudioTrack interne au streaming session du `Qwen3TtsEngine` (avec keepAlive
|
||||||
|
watchdog ColorOS) reste également figé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build / Backends
|
||||||
|
|
||||||
|
### ggml — sources autorisées
|
||||||
|
|
||||||
|
| Composant | Source ggml autorisée | Build dir |
|
||||||
|
|---|---|---|
|
||||||
|
| **TTS Decoder** | `chraac-llama` (branch `dev-refactoring`, commit `897501a`) | `build-android-static-chraac` |
|
||||||
|
| TTS Talker (lib) | jniLibs `libggml-cpu.so` (`llama-upstream`, à migrer Task #240) | `kazeia-android/.../jniLibs/arm64-v8a` |
|
||||||
|
| TTS CP (lib) | idem | idem |
|
||||||
|
| STT | n/a (ONNX Runtime, pas ggml) | n/a |
|
||||||
|
|
||||||
|
**Critique** : ne JAMAIS pointer le décodeur TTS vers `whisper.cpp/ggml` ou
|
||||||
|
`llama-upstream/ggml` — sinon RTF passe à 1.4-1.5 (mesuré, cf
|
||||||
|
`kazeia-tts-perf-validated/docs/MEASUREMENTS.md`).
|
||||||
|
|
||||||
|
### Compile flags figés
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
# TTS decoder (libtts_decoder_ggml.so)
|
||||||
|
-O3 -march=armv8.6-a+dotprod+fp16+i8mm+bf16 -fopenmp -static-openmp
|
||||||
|
GGML_CPU_ARM_ARCH=armv8.6-a+dotprod+fp16+i8mm+bf16
|
||||||
|
GGML_OPENMP=ON
|
||||||
|
BUILD_SHARED_LIBS=OFF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers de référence persistants
|
||||||
|
|
||||||
|
- `/opt/Kazeia/kazeia-stt-perf-validated/` — doc + MD5 modèles STT/VAD
|
||||||
|
- `/opt/Kazeia/kazeia-tts-perf-validated/` — doc + libs `.a` + bench CLI
|
||||||
|
reproductible TTS (RTF<1)
|
||||||
|
- `/opt/Kazeia/chraac-llama/` — fork ggml indispensable pour le décodeur TTS
|
||||||
|
- `/opt/Kazeia/kazeia-tts-decoder-ggml/build-android-static-chraac/` — build
|
||||||
|
statique pointé par l'app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comment dégeler / modifier ce contrat
|
||||||
|
|
||||||
|
**Procédure obligatoire** avant de réintroduire un composant supprimé OU de
|
||||||
|
remplacer un composant figé :
|
||||||
|
|
||||||
|
1. Mesurer la perf du composant cible **vs** la voie figée (sur la même phrase
|
||||||
|
de test, même tablette, même gouverneur DVFS)
|
||||||
|
2. Démontrer que la nouvelle voie est strictement meilleure (perf, RAM ou
|
||||||
|
maintenabilité) avec des chiffres reproductibles
|
||||||
|
3. Mettre à jour ce `FROZEN.md` avec le nouveau composant + les chiffres
|
||||||
|
4. Mettre à jour `MEMORY.md` index + memory file pertinent
|
||||||
|
5. Documenter le commit/build SHA exact dans `kazeia-{stt,tts}-perf-validated/`
|
||||||
|
6. Conserver l'ancien composant comme `*.archive.kt` UN trimestre avant
|
||||||
|
suppression définitive (filet de sécurité rollback)
|
||||||
|
|
||||||
|
Sans ce processus, **ne pas modifier la stack figée**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Métriques de référence (2026-05-02)
|
||||||
|
|
||||||
|
| Domaine | Métrique | Valeur figée | Comment |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| STT | Load total | 750-1035 ms | NPU encoder + decoder + mel filters |
|
||||||
|
| STT | RTF (audio 1.6 s) | **0.51** | mel + encoder + decoder NPU |
|
||||||
|
| VAD | Trigger end-of-speech | 800 ms après dernier audio > seuil | hardcoded |
|
||||||
|
| LLM | Mono-Speaker forward | ~22 tok/s | qnn_llama_runner subprocess |
|
||||||
|
| TTS Decoder | RTF (audio 4.48 s) | **0.96** | ggml C++ chraac-llama, standalone |
|
||||||
|
| TTS Pipeline complet | RTF | ~2.87 | bottleneck CP (Task #240 pending) |
|
||||||
|
|
@ -0,0 +1,508 @@
|
||||||
|
# Rapport Kazeia — État, architecture, performances
|
||||||
|
|
||||||
|
**Date :** 2026-05-04
|
||||||
|
**Contexte :** Kazeia est une application Android de soutien thérapeutique
|
||||||
|
conversationnel (psychologue virtuel) tournant **100 % on-device** sur OnePlus
|
||||||
|
Pad 3, sans connectivité réseau requise et sans root utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Synthèse exécutive
|
||||||
|
|
||||||
|
### Ce qui fonctionne en production
|
||||||
|
- Pipeline complet **STT → LLM → TTS** opérationnel, multi-tour, avec cascade
|
||||||
|
Thinker+Speaker.
|
||||||
|
- Latence end-to-end utilisable en conversation : **3-5 s** entre la fin du
|
||||||
|
PTT et le premier audio de réponse.
|
||||||
|
- Stable mémoire après débloat ColorOS + désinstallation du watchdog OEM Athena.
|
||||||
|
- Application 100 % autonome sur la tablette : aucun service réseau requis,
|
||||||
|
modèles et runtime tous embarqués.
|
||||||
|
- Stack figée et documentée (`/opt/Kazeia/FROZEN.md`).
|
||||||
|
- Backup tablette complet et automatisable dans `/opt/Kazeia/kazeia-tablet-backup/`.
|
||||||
|
|
||||||
|
### Limites actuelles connues
|
||||||
|
- Le **Thinker Guard-0.6B** produit des bullets de qualité analytique limitée
|
||||||
|
(modèle conçu pour classification de safety, détourné vers analyse clinique).
|
||||||
|
Le Speaker compense en pratique mais l'apport qualitatif de la cascade est
|
||||||
|
moins net qu'espéré.
|
||||||
|
- Le rendu UI de l'orbe (cycle couleurs pendant Thinking) demande encore
|
||||||
|
validation visuelle utilisateur.
|
||||||
|
- Conversation history non persisté entre sessions (à dessein actuellement).
|
||||||
|
- Pas de fine-tuning thérapeutique français spécifique sur les modèles —
|
||||||
|
on utilise Qwen3-4B générique pré-entraîné.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Plateforme matérielle
|
||||||
|
|
||||||
|
| Composant | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Tablette | OnePlus Pad 3 (OPD2415EEA) |
|
||||||
|
| RAM | 16 GB LPDDR5X |
|
||||||
|
| Stockage | 256 GB UFS |
|
||||||
|
| SoC | Snapdragon 8 Elite |
|
||||||
|
| CPU | Oryon (8 cœurs ARMv9) |
|
||||||
|
| GPU | Adreno 830 |
|
||||||
|
| **NPU/DSP** | **Hexagon V79 (HMX/HVX)** |
|
||||||
|
| OS | Android 16, ColorOS post-2026-03-18 OTA |
|
||||||
|
| Root | Aucun (user mode standard) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stack logicielle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Kazeia Android App (Kotlin) │
|
||||||
|
│ /data/app/.../com.kazeia │
|
||||||
|
└───────────────────┬─────────────────────────┬───────────────────┘
|
||||||
|
│ │
|
||||||
|
┌───────────▼──────────┐ ┌───────────▼──────────┐
|
||||||
|
│ KazeiaService │ │ ChatActivity (UI) │
|
||||||
|
│ (state machine) │ │ AudioVisualizerView│
|
||||||
|
│ • PTT │ │ (orb HSV cycle) │
|
||||||
|
│ • Cascade │ └──────────────────────┘
|
||||||
|
│ • Pipeline state │
|
||||||
|
└─┬────────┬────────┬──┘
|
||||||
|
│ │ │
|
||||||
|
┌──────▼─┐ ┌───▼────┐ ┌─▼──────┐
|
||||||
|
│ STT │ │ LLM │ │ TTS │
|
||||||
|
│ Engine │ │ Engine │ │ Engine │
|
||||||
|
└────────┘ └────────┘ └────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 STT — Whisper-Small NPU (QAIRT)
|
||||||
|
|
||||||
|
- **Modèle** : `HfWhisper-Small` (12 layers, 12 heads)
|
||||||
|
- **Format** : QAIRT context binary (Qualcomm SDK natif)
|
||||||
|
- **Fichiers** :
|
||||||
|
- `HfWhisperEncoder_qairt_context.bin` (210 MB)
|
||||||
|
- `HfWhisperDecoder_qairt_context.bin` (361 MB)
|
||||||
|
- **Backend** : Hexagon V79 via QAIRT runtime (`libQnnHtpV79Skel.so`)
|
||||||
|
- **VAD** : RMS énergie inline (seuil 80, frames 100 ms)
|
||||||
|
- **Mode** : continuous listening avec streaming Whisper pendant PTT
|
||||||
|
- **Performance mesurée** : RTF 0.4 (transcription "Bonjour" ~640 ms total :
|
||||||
|
mel 260 ms + encoder 115 ms + decoder 170 ms)
|
||||||
|
|
||||||
|
### 3.2 LLM — Cascade Option E v2 (in-process)
|
||||||
|
|
||||||
|
**Architecture cascade** : 2 `LlmModule` ExecuTorch dans le **même process app**,
|
||||||
|
partageant le `QnnBackendBundle` via le singleton `QnnBackendUnifiedRegistry`
|
||||||
|
(donc 1 seul handle fastrpc DSP, pas de collision).
|
||||||
|
|
||||||
|
```
|
||||||
|
Process com.kazeia
|
||||||
|
├── Speaker Qwen3-4B (.pte 3.3 GB, ~4.4 GB ION)
|
||||||
|
│ └── system prompt: SYS_KAZEIA + "Indices internes (NE PAS RÉPÉTER) : ..."
|
||||||
|
└── Thinker Qwen3Guard-Gen-0.6B (.pte 700 MB, lazy first PTT)
|
||||||
|
└── system prompt: SYS_THINKER (4 bullets émotion/besoin/approche/axe)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Composant | Modèle | Format | Taille | Quantization |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Speaker | Qwen/Qwen3-4B | ExecuTorch `.pte` QNN HTP V79 | 3.3 GB | Q4 W4A16 |
|
||||||
|
| Thinker | Qwen/Qwen3Guard-Gen-0.6B | ExecuTorch `.pte` QNN HTP V79 | 700 MB | Q4 W4A16 |
|
||||||
|
|
||||||
|
**Cascade sequence par tour utilisateur :**
|
||||||
|
1. STT → texte patient (transcription Whisper)
|
||||||
|
2. **Thinker pass** : `Guard-0.6B(SYS_THINKER + msg)` → 4 bullets cliniques
|
||||||
|
3. Bullets tronqués à 400 chars (sécurité prefill budget)
|
||||||
|
4. **Speaker pass** : `Qwen3-4B(SYS_KAZEIA + bullets en system, msg en user)` → réponse 1-2 phrases
|
||||||
|
5. Streaming TTS phrase par phrase au fur et à mesure du décodage
|
||||||
|
|
||||||
|
### 3.3 TTS — Qwen3-TTS ggml-cpu
|
||||||
|
|
||||||
|
- **Modèle source** : Qwen3-TTS-0.6B-Base (clonage vocal)
|
||||||
|
- **Pipeline 3 étapes**, toutes en CPU pur (NEON ARMv9, pas de DSP) :
|
||||||
|
|
||||||
|
| Étape | Modèle | Fichier | Taille | Backend |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Talker | transformer audio → tokens semantic | `talker_f16.gguf` | 851 MB | ggml-cpu |
|
||||||
|
| Code Predictor (CP) | sem → 16 codebooks RVQ | `cp_q8_0.gguf` | 84 MB + 240 MB embeds | ggml-cpu |
|
||||||
|
| Decoder | RVQ → mel → audio 24 kHz | `qwen3tts_decoder.gguf` | 325 MB | ggml-cpu (libtts_decoder_ggml) |
|
||||||
|
|
||||||
|
- **Voice cloning** : prefix/suffix embeddings pré-extraits (10 voix : damien,
|
||||||
|
elodie, jerome, richard, sid, zelda, didier, amir...)
|
||||||
|
- **Streaming sentence-level** : audio joue dès la 1ère phrase pendant que LLM
|
||||||
|
décode encore les suivantes (Opt TTS-B)
|
||||||
|
- **RTF mesuré** : 0.96 sur tablette (chraac-llama fork)
|
||||||
|
|
||||||
|
### 3.4 Glue applicative
|
||||||
|
|
||||||
|
| Composant | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `KazeiaService` (Kotlin) | State machine PTT + cascade + pipeline |
|
||||||
|
| `ExecuTorchLlmEngine` | Wrapper Java→JNI sur `LlmModule` ExecuTorch |
|
||||||
|
| `Qwen3TtsEngine` | Orchestration JNI Talker+CP+Decoder + voice clone |
|
||||||
|
| `WhisperHybridEngine` | JNI QAIRT + mel extractor NEON |
|
||||||
|
| `AudioVisualizerView` | Orbe Canvas, HSV cycle pendant Thinking |
|
||||||
|
| `SentenceStreamer` | Découpage phrases pour streaming TTS |
|
||||||
|
| `PipelineMetrics` | Instrumentation timings end-to-end |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Performances mesurées
|
||||||
|
|
||||||
|
### 4.1 Latences par étape (mesures réelles dans logcat)
|
||||||
|
|
||||||
|
| Étape | Temps typique | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| **STT Whisper-Small** | 460-680 ms | RTF 0.4 sur audio 1.7s |
|
||||||
|
| ├ Mel extraction | 190-270 ms | NEON sur CPU |
|
||||||
|
| ├ Encoder NPU | 115 ms | QAIRT V79 |
|
||||||
|
| └ Decoder NPU | 75-300 ms | dépend du nb tokens |
|
||||||
|
| **Thinker (Guard-0.6B)** | 446 ms | 22 tokens à 49 tok/s, TTFT 130 ms |
|
||||||
|
| ├ Lazy load (1ère fois) | 1 088 ms | -844 MB MemAvailable |
|
||||||
|
| └ Inference | 446 ms | 4 bullets, prompt ~180 tok |
|
||||||
|
| **Speaker (Qwen3-4B)** | 1 000-2 200 ms | 14-46 tokens à 14-21 tok/s, TTFT 190 ms |
|
||||||
|
| ├ resetContext | <1 ms | clear KV cache |
|
||||||
|
| ├ Prefill (~150 tok) | 200 ms | hybrid mode AR=128 |
|
||||||
|
| └ Decode | 800-2000 ms | 13.9-21 tok/s selon longueur |
|
||||||
|
| **TTS Talker step** | ~25 ms | ggml-cpu NEON |
|
||||||
|
| **TTS CP step** | ~43 ms | Q8_0, optimisé |
|
||||||
|
| **TTS Decoder full** | RTF 0.96 | ggml-cpu, premier audio <1 s |
|
||||||
|
|
||||||
|
### 4.2 Latence end-to-end PTT → 1ᵉʳ audio
|
||||||
|
|
||||||
|
```
|
||||||
|
PTT release ─┬───── 100 ms finalize segment
|
||||||
|
│
|
||||||
|
╔═══ 600 ms ═══════ STT Whisper ─┐
|
||||||
|
║ │
|
||||||
|
╠═══ 450 ms ═══════ Thinker Guard-0.6B │ utilisateur
|
||||||
|
║ │ entend silence
|
||||||
|
╠═══ 500 ms ═══════ Speaker prefill + 1ère phrase │
|
||||||
|
║ │
|
||||||
|
╠═══ 800 ms ═══════ TTS 1ère phrase synthèse ─┘
|
||||||
|
║
|
||||||
|
▼ 1ᵉʳ audio joué (~ 2.5 s après PTT release)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total PTT release → 1ᵉʳ audio : ~ 2.5 s** (pour message court).
|
||||||
|
**Total PTT release → réponse complète audio : ~ 4-6 s**.
|
||||||
|
|
||||||
|
### 4.3 Throughput LLM mesuré
|
||||||
|
|
||||||
|
| Modèle | Hardware | Decode tok/s | TTFT |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Qwen3-4B Speaker | Hexagon V79 | 14-21 | 190 ms |
|
||||||
|
| Guard-0.6B Thinker | Hexagon V79 | **49** | 130 ms |
|
||||||
|
| Whisper-Small | Hexagon V79 | n/a (encoder/decoder fusion) | 115+75 ms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Profil mémoire
|
||||||
|
|
||||||
|
### 5.1 Tablette en idle (Speaker chargé, Thinker pas encore)
|
||||||
|
|
||||||
|
| Métrique | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| MemAvailable | 5.10 GB |
|
||||||
|
| Cached (file cache) | 4.51 GB |
|
||||||
|
| Process Kazeia RSS | 2.06 GB |
|
||||||
|
| Process Kazeia PSS total | 2.14 GB |
|
||||||
|
|
||||||
|
### 5.2 Décomposition PSS Kazeia (idle)
|
||||||
|
|
||||||
|
| Section | PSS | % |
|
||||||
|
|---|---|---|
|
||||||
|
| Native Heap (modèles, ION, native libs) | 1.65 GB | 77 % |
|
||||||
|
| Java/Dalvik Heap | 362 MB | 17 % |
|
||||||
|
| Graphics (EGL/GL) | 64 MB | 3 % |
|
||||||
|
| Code (.so + .apk + .dex mmap) | 42 MB | 2 % |
|
||||||
|
| Stack/divers | 26 MB | 1 % |
|
||||||
|
| **TOTAL PSS** | **2.14 GB** | |
|
||||||
|
|
||||||
|
### 5.3 Pendant cascade et TTS
|
||||||
|
|
||||||
|
| | Idle | Cascade Thinker | TTS pic |
|
||||||
|
|---|---|---|---|
|
||||||
|
| RSS Kazeia | 2.06 GB | ~2.9 GB | ~3.5 GB |
|
||||||
|
| ION DSP cumulé | ~3.3 GB | ~5.0 GB | ~5.5 GB |
|
||||||
|
| MemAvailable | 5.10 GB | 4.0 GB | 3.0 GB |
|
||||||
|
|
||||||
|
Le pic TTS reste loin du seuil critique (~1 GB MemAvailable où Linux LMK
|
||||||
|
commencerait à killer). **Stable**.
|
||||||
|
|
||||||
|
### 5.4 Tablet baseline (sans Kazeia)
|
||||||
|
|
||||||
|
- **Stock fraîche** : MemAvailable ~3.5 GB (900+ processus OEM ColorOS)
|
||||||
|
- **Après débloat + reboot** : MemAvailable ~11.1 GB → **+7.7 GB de marge**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Trajectoire — Ce qui a été fait
|
||||||
|
|
||||||
|
### Phase A : Pipeline initial (avril 2026)
|
||||||
|
- Migration v1 du PoC Python/Flask vers app Android Kotlin native
|
||||||
|
- Speaker via ExecuTorch QNN delegate (Qwen3-4B `.pte` hybrid mode)
|
||||||
|
- STT Whisper-Small NPU via QAIRT
|
||||||
|
- TTS Qwen3-TTS originalement en ExecuTorch `.pte`
|
||||||
|
|
||||||
|
### Phase B : Migration TTS vers ggml-cpu (avril-mai 2026)
|
||||||
|
- Talker exporté en GGUF f16 (vs `.pte` ExecuTorch)
|
||||||
|
- CP exporté en GGUF Q8_0 (84 MB vs 316 MB f32 vs 158 MB f16)
|
||||||
|
- Decoder porté en C++ via libtts_decoder_ggml.so
|
||||||
|
- Validation RTF < 1 sur tablette (chraac-llama fork)
|
||||||
|
|
||||||
|
### Phase C : Tablette stabilisation (mai 2026)
|
||||||
|
- Mesure baseline ColorOS : ~3.5 GB MemAvailable seulement
|
||||||
|
- **Débloat** sans root : 84 packages désinstallés via `pm uninstall --user 0`
|
||||||
|
- **Athena watchdog** : désinstallation du package (kill ION quota 2 GB sur app)
|
||||||
|
- Suppression de symlinks ⚠ SELinux ColorOS sans Magisk les bloque
|
||||||
|
- Backup complet 25 GB dans `/opt/Kazeia/kazeia-tablet-backup/`
|
||||||
|
- Factory reset + restore réussi
|
||||||
|
|
||||||
|
### Phase D : Cascade Thinker+Speaker (mai 2026)
|
||||||
|
Tentatives successives :
|
||||||
|
|
||||||
|
| Tentative | Approche | Résultat |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Subprocess `qnn_llama_runner` daemon | ✗ Collision DSP fastrpc (2 sessions QNN HTP) |
|
||||||
|
| 2 | Option E v1 : 2 LlmModule (Speaker 4B + Guard 4B) | ✗ OOM Linux LMK (8.8 GB ION cumulé) |
|
||||||
|
| 3 | Option B : 2-pass sur même Speaker | ✓ Stable, qualité Thinker faible (Qwen3-4B générique) |
|
||||||
|
| 4 | **Option E v2** : Speaker 4B + Guard-0.6B | **✓ Production actuelle** |
|
||||||
|
|
||||||
|
Pour Option E v2 : export Qwen3Guard-Gen-0.6B HF → ExecuTorch `.pte` QNN HTP V79
|
||||||
|
en local via `executorch.examples.qualcomm.oss_scripts.llama` (registration
|
||||||
|
ajoutée dans `__init__.py` + `decoder_constants.py`).
|
||||||
|
|
||||||
|
### Phase E : Robustesse runtime (mai 2026)
|
||||||
|
- Fix VAD seuil RMS (150 → 80) après calibration mic post-reset
|
||||||
|
- Fix PTT release : finalisation forcée du segment (sans ça, le LLM n'était
|
||||||
|
jamais déclenché si bruit ambiant > seuil)
|
||||||
|
- Fix `seq_len` ExecuTorch : passer 512 (max compilé .pte) au lieu de
|
||||||
|
`maxNewTokens` (assertion `cur_pos+prompt < seq_len`)
|
||||||
|
- Fix `cur_pos_` accumulation : appel `LlmModule.resetContext()` à chaque
|
||||||
|
generate (sinon crash après quelques tours)
|
||||||
|
- Fix `prefill()` overflow : truncation bullets à 400 chars (Guard-0.6B peut
|
||||||
|
rambler et faire dépasser le budget prefill du Speaker)
|
||||||
|
- Fix Speaker régurgitant les bullets : injection bullets dans **system prompt**
|
||||||
|
(avec instruction « NE PAS RÉPÉTER ») au lieu du user prompt
|
||||||
|
- Fix orbe visuelle : `Paint.shader = null` avant pose couleur dans
|
||||||
|
`drawThinking` (sinon le shader posé par `drawIdle` masquait la couleur)
|
||||||
|
|
||||||
|
### Phase F : Backup APK + scripts (en continu)
|
||||||
|
- `/opt/Kazeia/kazeia-tablet-backup/` (25 GB total)
|
||||||
|
- APK actuelle (110 MB, libs natives custom bundlées)
|
||||||
|
- Modèles TTS+STT (12 GB)
|
||||||
|
- LLM `.pte` (12 GB inc. Guard-0.6B nouveau)
|
||||||
|
- Voix de référence (130 MB)
|
||||||
|
- `restore.sh` automatisé
|
||||||
|
- `/opt/Kazeia/kazeia-debloat/` : `safelist.txt` + `debloat.sh` + `rebloat.sh`
|
||||||
|
- `/opt/Kazeia/export_guard_06b.sh` : pipeline d'export Guard-0.6B reproducible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Architecture cascade détaillée (Option E v2)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ KazeiaService │
|
||||||
|
│ │
|
||||||
|
│ PTT release ───► startContinuousListening() drains buffer │
|
||||||
|
│ ► processSpeechInput / streaming Whisper │
|
||||||
|
│ ► text = "Comment ça va ?" │
|
||||||
|
│ ► processLlmResponse(text) │
|
||||||
|
│ │
|
||||||
|
│ ┌─ ensureThinkerEngine() ──┐ (lazy 1ère fois ~1.1 s) │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ ┌─────────────────────┐ │
|
||||||
|
│ │ │ thinkerEngine │ │
|
||||||
|
│ │ │ ExecuTorchLlmEngine │ │
|
||||||
|
│ │ │ Guard-0.6B │ │
|
||||||
|
│ │ │ SYS_THINKER │ │
|
||||||
|
│ │ │ T=0.0, max 128 tok │ │
|
||||||
|
│ │ └──────────┬──────────┘ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ bullets (400 chars max) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────┴──────┐ │
|
||||||
|
│ │ │ speakerSystemPrompt = │ │
|
||||||
|
│ │ │ SYS_KAZEIA + │ │
|
||||||
|
│ │ │ "\n\nIndices d'analyse... │ │
|
||||||
|
│ │ │ (NE PAS CITER) :\n" + │ │
|
||||||
|
│ │ │ bullets │ │
|
||||||
|
│ │ └────────────┬────────────────────┘ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ llm = ExecuTorchLlmEngine │ │
|
||||||
|
│ │ │ Speaker Qwen3-4B │ │
|
||||||
|
│ │ │ generateWithSystem() │ │
|
||||||
|
│ │ │ system=above, user=msg │ │
|
||||||
|
│ │ │ T=0.7, max 450 tok │ │
|
||||||
|
│ │ └──────────┬───────────────┘ │
|
||||||
|
│ │ ▼ token-by-token streaming │
|
||||||
|
│ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ SentenceStreamer │ │
|
||||||
|
│ │ │ split sur . ! ? │ │
|
||||||
|
│ │ └──────────┬───────────────┘ │
|
||||||
|
│ │ ▼ phrase à phrase │
|
||||||
|
│ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ Qwen3TtsEngine │ │
|
||||||
|
│ │ │ enqueueSentence() │ │
|
||||||
|
│ │ └──────────┬───────────────┘ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ └─────► AudioPlayback (24 kHz mono, AudioTrack) │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ DSP Hexagon V79 │
|
||||||
|
│ • QAIRT (Whisper) │
|
||||||
|
│ • ExecuTorch QNN HTP │
|
||||||
|
│ (Speaker + Thinker │
|
||||||
|
│ partagent bundle) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ CPU NEON ARMv9 │
|
||||||
|
│ • TTS Talker (ggml) │
|
||||||
|
│ • TTS CP (ggml) │
|
||||||
|
│ • TTS Decoder (ggml) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Prompts système (production)
|
||||||
|
|
||||||
|
### Speaker
|
||||||
|
```
|
||||||
|
Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français :
|
||||||
|
UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple.
|
||||||
|
Pas de remplissage, pas d'explication, pas de conseil non sollicité.
|
||||||
|
/no_think
|
||||||
|
|
||||||
|
[ + injection en cascade : ]
|
||||||
|
Indices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement
|
||||||
|
à orienter le ton de ta réponse) :
|
||||||
|
- emotion : ...
|
||||||
|
- besoin : ...
|
||||||
|
- approche : ...
|
||||||
|
- axe : ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Thinker
|
||||||
|
```
|
||||||
|
Tu es un psychologue clinicien experimente qui ecoute attentivement ton
|
||||||
|
patient. Avant de repondre, tu prepares une analyse silencieuse pour orienter
|
||||||
|
ta reponse. Produis UNIQUEMENT le bloc clinique au format strict suivant :
|
||||||
|
- emotion : <emotion principale ressentie, 5 mots max>
|
||||||
|
- besoin : <besoin sous-jacent du patient, 5 mots max>
|
||||||
|
- approche : <posture therapeutique adaptee, 5 mots max>
|
||||||
|
- axe : <point cle a valider ou explorer, 5 mots max>
|
||||||
|
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais.
|
||||||
|
/no_think
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risques connus & mitigations
|
||||||
|
|
||||||
|
| Risque | Impact | Mitigation actuelle | Action future |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Athena framework (system_server) tue Kazeia au-dessus de seuil RAM | Crash app | Débloat OEM réduit pression mémoire | Investiguer si killable plus profond |
|
||||||
|
| LMK Linux kill si MemAvailable < 1 GB | Crash app | Marge 3 GB en pic actuel | Lazy-load STT (-600 MB) |
|
||||||
|
| Speaker régurgite bullets cascade | Phrases bizarres lues par TTS | Bullets en system prompt + interdiction | Fine-tune/instruction tuning |
|
||||||
|
| Thinker Guard-0.6B mal calibré pour analyse FR | Bullets imprécis | Speaker compense par sa robustesse | Évaluer Qwen3-1.7B en remplacement |
|
||||||
|
| Whisper hallucinations sur silence | "que tu entends ce que je dis ici." apparaît au repos | Ignorer streaming results vides | Filtre RMS/longueur audio min |
|
||||||
|
| Orbe ne change pas (avant fix) | UX dégradée | `Paint.shader = null` dans drawThinking | OK |
|
||||||
|
| Symlinks `.pte` invisibles app | Mode perroquet | Fichiers réguliers (cp pas ln) | OK, mémoire feedback enregistrée |
|
||||||
|
| OOM 2× ExecuTorch HTP 4B | Crash | Thinker = Guard-0.6B (5.1 GB ION total OK) | Plus petit Thinker possible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Optimisations identifiées (memory profiling 2026-05-04)
|
||||||
|
|
||||||
|
| Optim | Effort | Gain idle | Gain pic | Risque | Recommandation |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Strip jniLibs Hexagon non-V79 | 30 min | 50 MB code | 50 MB | Bas | **Tier 1** |
|
||||||
|
| Lazy-load Whisper STT | 1 h | 600 MB | 0 | Moyen (+0.5s 1ᵉʳ PTT) | **Tier 1** |
|
||||||
|
| Trim Java heap (logs StateFlow) | 1 h | 100-200 MB | 100 MB | Bas | **Tier 1** |
|
||||||
|
| Release Thinker entre tours | 2 h | 0 | -700 MB | Moyen (+1.5s 2ᵉ PTT) | Tier 2 |
|
||||||
|
| Talker GGUF Q8_0 (vs f16) | 1 j | 400 MB | 400 MB | Haut (qualité audio) | Tier 2 |
|
||||||
|
| Decoder Q4 sélectif | 2-3 j | 200 MB | 200 MB | Haut | Tier 3 |
|
||||||
|
| Re-export Speaker plus petit | 5-10 j | 500-1 000 MB | similar | Très haut | Tier 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Roadmap
|
||||||
|
|
||||||
|
### Court terme (1-2 semaines)
|
||||||
|
- [ ] Appliquer Tier 1 mémoire (strip libs + lazy STT + trim heap)
|
||||||
|
- [ ] Évaluation qualitative cascade FR sur corpus thérapeutique
|
||||||
|
20+ prompts de référence
|
||||||
|
- [ ] Comparer Guard-0.6B vs Qwen3-1.7B comme Thinker (export et bench)
|
||||||
|
- [ ] Documenter procédure réinstallation tablette neuve (incl. débloat)
|
||||||
|
|
||||||
|
### Moyen terme (1-2 mois)
|
||||||
|
- [ ] Rewrite Kazeia Phase 1-5 (architecture propre Kotlin/JNI)
|
||||||
|
- [ ] Voice clone .bin generator on-device (POC)
|
||||||
|
- [ ] Persistance conversation history (Room database)
|
||||||
|
- [ ] UI polish (mode conversation libre vs PTT)
|
||||||
|
- [ ] Voice commands étendus
|
||||||
|
|
||||||
|
### Long terme (3+ mois)
|
||||||
|
- [ ] Fine-tuning thérapeutique français spécifique (LoRA)
|
||||||
|
- [ ] Migration runtime briques (POC dans `/opt/htp-runtime/`) — alternative
|
||||||
|
à ExecuTorch/QNN sur le long terme pour autonomie
|
||||||
|
- [ ] Évaluation clinique avec professionnels santé mentale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Références — Fichiers clés
|
||||||
|
|
||||||
|
### Code source app
|
||||||
|
- `/opt/Kazeia/kazeia-android/` — projet Gradle 8.12 / NDK r27d / API 36
|
||||||
|
- `app/src/main/java/com/kazeia/service/KazeiaService.kt` — orchestrateur
|
||||||
|
- `app/src/main/java/com/kazeia/llm/ExecuTorchLlmEngine.kt` — wrapper LLM
|
||||||
|
- `app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt` — TTS
|
||||||
|
- `app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt` — STT
|
||||||
|
- `app/src/main/java/com/kazeia/ui/AudioVisualizerView.kt` — orbe
|
||||||
|
- `app/src/main/jni/` — bridges JNI custom (libtts_*, libmel_extractor)
|
||||||
|
|
||||||
|
### Scripts et outils
|
||||||
|
- `/opt/Kazeia/export_guard_06b.sh` — export Qwen3Guard-Gen-0.6B → `.pte`
|
||||||
|
- `/opt/Kazeia/kazeia-debloat/` — débloat tablette
|
||||||
|
- `/opt/Kazeia/kazeia-tablet-backup/` — backup + restore
|
||||||
|
- `/opt/Kazeia/executorch/examples/qualcomm/oss_scripts/llama/` — pipeline export QNN HTP
|
||||||
|
|
||||||
|
### Modèles tablette
|
||||||
|
- `/data/local/tmp/kazeia-et/hybrid_llama_qnn_4b.pte` — Speaker
|
||||||
|
- `/data/local/tmp/kazeia-et/hybrid_llama_qnn_guard06b.pte` — Thinker (nouveau)
|
||||||
|
- `/data/local/tmp/kazeia-et/tokenizer.json` / `tokenizer_guard06b.json`
|
||||||
|
- `/data/local/tmp/kazeia/models/` — Talker, CP, Decoder GGUF, Whisper QAIRT
|
||||||
|
- `/data/local/tmp/kazeia/voix/` — voix de référence
|
||||||
|
|
||||||
|
### Documents de référence
|
||||||
|
- `/opt/Kazeia/FROZEN.md` — stack figée
|
||||||
|
- `/opt/Kazeia/kazeia-tts-perf-validated/` — TTS RTF<1 validé
|
||||||
|
- `/opt/Kazeia/kazeia-stt-perf-validated/` — STT RTF 0.4 validé
|
||||||
|
- `/opt/Kazeia/kazeia-debloat/README.md` — procédure tablette
|
||||||
|
|
||||||
|
### Mémoire persistante (sessions Claude Code)
|
||||||
|
- `/home/alf/.claude/projects/-opt-Kazeia/memory/MEMORY.md` — index
|
||||||
|
- 50+ entrées documentant chaque décision technique majeure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Conclusion
|
||||||
|
|
||||||
|
Kazeia tourne **end-to-end stable** sur OnePlus Pad 3 sans connexion internet,
|
||||||
|
sans root utilisateur, avec des latences acceptables pour une conversation
|
||||||
|
thérapeutique (3-5 s entre tours). L'architecture cascade Speaker+Thinker
|
||||||
|
in-process via ExecuTorch QNN HTP est validée et reproductible.
|
||||||
|
|
||||||
|
Les principaux **blocages historiques** ont été résolus : DSP contention,
|
||||||
|
OOM, SELinux symlinks, watchdog OEM, accumulation KV cache. La stack est
|
||||||
|
documentée et figée, avec backup + procédure de restore complets.
|
||||||
|
|
||||||
|
Les **axes d'amélioration restants** sont incrémentaux (mémoire, qualité
|
||||||
|
Thinker, UX) plutôt que structurels. Le pipeline est prêt pour **évaluation
|
||||||
|
clinique** sur corpus de référence en français.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Rapport généré 2026-05-04. Auteur : Richard Loyer + assistance IA.*
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Export Qwen3-TTS speech decoder V2 to full ONNX for GPU QNN backend.
|
||||||
|
Exact architecture from state dict analysis:
|
||||||
|
pre_conv: Conv1d(512, 1024, kernel=3, pad=1)
|
||||||
|
pre_transformer: 8-layer transformer (dim=512, attn_dim=1024, heads=16, head_dim=64)
|
||||||
|
upsample: 2× ConvTranspose1d(1024,1024) + ConvNeXtV2
|
||||||
|
BigVGAN: 7 blocks with Snake activation, upsample_rates=[8,5,4,3]
|
||||||
|
"""
|
||||||
|
import sys, os, math, warnings
|
||||||
|
sys.path.insert(0, "/opt/Kazeia/qnn_venv/lib/python3.10/site-packages")
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
state = torch.load("/opt/Kazeia/models_qnn/qwen3-tts-native/speech_decoder_weights.pt", map_location="cpu")
|
||||||
|
print(f"Loaded {len(state)} tensors")
|
||||||
|
|
||||||
|
OUT = "/opt/Kazeia/models_qnn/qwen3-tts-decoder-full-onnx"
|
||||||
|
for d in ["pre_conv", "preprocessor", "conv_decoder"]:
|
||||||
|
os.makedirs(f"{OUT}/{d}", exist_ok=True)
|
||||||
|
|
||||||
|
SEQ = 60
|
||||||
|
|
||||||
|
# ===== RMSNorm =====
|
||||||
|
class RMSNorm(nn.Module):
|
||||||
|
def __init__(self, dim, eps=1e-5):
|
||||||
|
super().__init__()
|
||||||
|
self.weight = nn.Parameter(torch.ones(dim))
|
||||||
|
self.eps = eps
|
||||||
|
def forward(self, x):
|
||||||
|
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) * self.weight
|
||||||
|
|
||||||
|
# ===== DiT Layer =====
|
||||||
|
class DiTLayer(nn.Module):
|
||||||
|
def __init__(self, dim=512, attn_dim=1024, n_heads=16, head_dim=64, ffn_dim=1024, rope_theta=10000.0):
|
||||||
|
super().__init__()
|
||||||
|
self.dim = dim
|
||||||
|
self.n_heads = n_heads
|
||||||
|
self.head_dim = head_dim
|
||||||
|
self.input_layernorm = RMSNorm(dim)
|
||||||
|
self.post_attention_layernorm = RMSNorm(dim)
|
||||||
|
self.self_attn = nn.Module()
|
||||||
|
self.self_attn.q_proj = nn.Linear(dim, attn_dim, bias=False)
|
||||||
|
self.self_attn.k_proj = nn.Linear(dim, attn_dim, bias=False)
|
||||||
|
self.self_attn.v_proj = nn.Linear(dim, attn_dim, bias=False)
|
||||||
|
self.self_attn.o_proj = nn.Linear(attn_dim, dim, bias=False)
|
||||||
|
self.self_attn_layer_scale = nn.Module()
|
||||||
|
self.self_attn_layer_scale.scale = nn.Parameter(torch.ones(dim) * 0.01)
|
||||||
|
self.mlp = nn.Module()
|
||||||
|
self.mlp.gate_proj = nn.Linear(dim, ffn_dim, bias=False)
|
||||||
|
self.mlp.up_proj = nn.Linear(dim, ffn_dim, bias=False)
|
||||||
|
self.mlp.down_proj = nn.Linear(ffn_dim, dim, bias=False)
|
||||||
|
self.mlp_layer_scale = nn.Module()
|
||||||
|
self.mlp_layer_scale.scale = nn.Parameter(torch.ones(dim) * 0.01)
|
||||||
|
# Pre-compute RoPE
|
||||||
|
freqs = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2).float() / head_dim))
|
||||||
|
t = torch.arange(512).float()
|
||||||
|
emb = torch.outer(t, freqs)
|
||||||
|
self.register_buffer("rope_cos", emb.cos())
|
||||||
|
self.register_buffer("rope_sin", emb.sin())
|
||||||
|
|
||||||
|
def apply_rope(self, x, seq_len):
|
||||||
|
cos = self.rope_cos[:seq_len].unsqueeze(0).unsqueeze(0) # [1,1,T,D/2]
|
||||||
|
sin = self.rope_sin[:seq_len].unsqueeze(0).unsqueeze(0)
|
||||||
|
x1, x2 = x[..., ::2], x[..., 1::2]
|
||||||
|
out = torch.stack([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
|
||||||
|
return out.flatten(-2)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
B, T, _ = x.shape
|
||||||
|
h = self.input_layernorm(x)
|
||||||
|
q = self.self_attn.q_proj(h).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
|
||||||
|
k = self.self_attn.k_proj(h).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
|
||||||
|
v = self.self_attn.v_proj(h).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
|
||||||
|
q = self.apply_rope(q, T)
|
||||||
|
k = self.apply_rope(k, T)
|
||||||
|
attn = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
|
||||||
|
mask = torch.triu(torch.full((T, T), float('-inf'), device=x.device), diagonal=1)
|
||||||
|
attn = attn + mask.unsqueeze(0).unsqueeze(0)
|
||||||
|
attn = F.softmax(attn, dim=-1)
|
||||||
|
out = torch.matmul(attn, v).transpose(1, 2).contiguous().view(B, T, -1)
|
||||||
|
x = x + self.self_attn_layer_scale.scale * self.self_attn.o_proj(out)
|
||||||
|
h2 = self.post_attention_layernorm(x)
|
||||||
|
ffn = self.mlp.down_proj(F.silu(self.mlp.gate_proj(h2)) * self.mlp.up_proj(h2))
|
||||||
|
x = x + self.mlp_layer_scale.scale * ffn
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== Pre-transformer =====
|
||||||
|
class PreTransformer(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.input_proj = nn.Linear(1024, 512)
|
||||||
|
self.layers = nn.ModuleList([DiTLayer() for _ in range(8)])
|
||||||
|
self.norm = RMSNorm(512)
|
||||||
|
self.output_proj = nn.Linear(512, 1024)
|
||||||
|
def forward(self, x):
|
||||||
|
x = x.transpose(1, 2) # [B,C,T] → [B,T,C]
|
||||||
|
x = self.input_proj(x)
|
||||||
|
for layer in self.layers:
|
||||||
|
x = layer(x)
|
||||||
|
x = self.norm(x)
|
||||||
|
x = self.output_proj(x)
|
||||||
|
return x.transpose(1, 2) # [B,T,C] → [B,C,T]
|
||||||
|
|
||||||
|
# ===== ConvNeXt-V2 =====
|
||||||
|
class ConvNeXtV2(nn.Module):
|
||||||
|
def __init__(self, dim=1024, int_dim=4096):
|
||||||
|
super().__init__()
|
||||||
|
self.dwconv = nn.Module()
|
||||||
|
self.dwconv.conv = nn.Conv1d(dim, dim, 7, padding=3, groups=dim)
|
||||||
|
self.norm = nn.LayerNorm(dim)
|
||||||
|
self.pwconv1 = nn.Linear(dim, int_dim)
|
||||||
|
self.pwconv2 = nn.Linear(int_dim, dim)
|
||||||
|
self.gamma = nn.Parameter(torch.ones(dim) * 0.01)
|
||||||
|
def forward(self, x):
|
||||||
|
r = x
|
||||||
|
x = self.dwconv.conv(x)
|
||||||
|
x = x.transpose(1, 2)
|
||||||
|
x = self.norm(x)
|
||||||
|
x = F.gelu(self.pwconv1(x))
|
||||||
|
x = self.pwconv2(x)
|
||||||
|
x = (self.gamma * x).transpose(1, 2)
|
||||||
|
return r + x
|
||||||
|
|
||||||
|
# ===== Upsample =====
|
||||||
|
class Upsample(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# upsampling_ratios=[2,2] from config
|
||||||
|
# ConvTranspose1d(1024,1024,kernel=2*ratio,stride=ratio,padding=ratio//2)
|
||||||
|
w0 = state["upsample.0.0.conv.weight"]
|
||||||
|
w1 = state["upsample.1.0.conv.weight"]
|
||||||
|
# For upsampling ratio r: kernel=2*r, stride=r, pad=r//2
|
||||||
|
# But kernel=2 could mean ratio=1 or ratio=2 with pad adjustment
|
||||||
|
# From config: upsampling_ratios=[2,2], so stride=2 for both
|
||||||
|
# kernel=2, stride=2, padding=0 gives exact 2× upsample
|
||||||
|
self.conv0 = nn.ConvTranspose1d(w0.shape[0], w0.shape[1], w0.shape[2], stride=2, padding=0)
|
||||||
|
self.cnx0 = ConvNeXtV2()
|
||||||
|
self.conv1 = nn.ConvTranspose1d(w1.shape[0], w1.shape[1], w1.shape[2], stride=2, padding=0)
|
||||||
|
self.cnx1 = ConvNeXtV2()
|
||||||
|
print(f" Upsample 0: {w0.shape} stride=2")
|
||||||
|
print(f" Upsample 1: {w1.shape} stride=2")
|
||||||
|
def forward(self, x):
|
||||||
|
x = self.conv0(x)
|
||||||
|
x = self.cnx0(x)
|
||||||
|
x = self.conv1(x)
|
||||||
|
x = self.cnx1(x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== SnakeBeta =====
|
||||||
|
class SnakeBeta(nn.Module):
|
||||||
|
def __init__(self, channels):
|
||||||
|
super().__init__()
|
||||||
|
# Stored as [channels] in checkpoint, reshaped to [1,channels,1] in forward
|
||||||
|
self.alpha = nn.Parameter(torch.ones(channels))
|
||||||
|
self.beta = nn.Parameter(torch.ones(channels))
|
||||||
|
def forward(self, x):
|
||||||
|
a = self.alpha.view(1, -1, 1)
|
||||||
|
b = self.beta.view(1, -1, 1)
|
||||||
|
return x + (1.0 / (b + 1e-9)) * (torch.sin(a * x) ** 2)
|
||||||
|
|
||||||
|
# ===== AMPBlock (BigVGAN resblock) =====
|
||||||
|
class AMPBlock(nn.Module):
|
||||||
|
def __init__(self, channels, kernel_size=7):
|
||||||
|
super().__init__()
|
||||||
|
self.act1 = SnakeBeta(channels)
|
||||||
|
self.act2 = SnakeBeta(channels)
|
||||||
|
self.conv1 = nn.Module()
|
||||||
|
self.conv1.conv = nn.Conv1d(channels, channels, kernel_size, padding=kernel_size//2)
|
||||||
|
self.conv2 = nn.Module()
|
||||||
|
self.conv2.conv = nn.Conv1d(channels, channels, kernel_size, padding=1)
|
||||||
|
def forward(self, x):
|
||||||
|
h = self.act1(x)
|
||||||
|
h = self.conv1.conv(h)
|
||||||
|
h = self.act2(h)
|
||||||
|
h = self.conv2.conv(h)
|
||||||
|
return x + h
|
||||||
|
|
||||||
|
# ===== UpsampleBigVGANBlock =====
|
||||||
|
class UpsampleBigVGANBlock(nn.Module):
|
||||||
|
def __init__(self, ch_in, ch_out, kernel, rate, pad, block_idx):
|
||||||
|
super().__init__()
|
||||||
|
self.block = nn.ModuleList()
|
||||||
|
self.block.append(SnakeBeta(ch_in))
|
||||||
|
self.block.append(nn.ConvTranspose1d(ch_in, ch_out, kernel, stride=rate, padding=pad))
|
||||||
|
for j in range(3):
|
||||||
|
amp_w1 = state[f"decoder.{block_idx}.block.{j+2}.conv1.conv.weight"]
|
||||||
|
amp_w2 = state[f"decoder.{block_idx}.block.{j+2}.conv2.conv.weight"]
|
||||||
|
amp = AMPBlock(amp_w1.shape[0], amp_w1.shape[2])
|
||||||
|
amp.conv2.conv = nn.Conv1d(amp_w2.shape[1], amp_w2.shape[0], amp_w2.shape[2], padding=amp_w2.shape[2]//2)
|
||||||
|
self.block.append(amp)
|
||||||
|
def forward(self, x):
|
||||||
|
# block[0] = SnakeBeta, block[1] = ConvTranspose1d, block[2-4] = AMPBlocks
|
||||||
|
x = self.block[0](x)
|
||||||
|
x = self.block[1](x)
|
||||||
|
for i in range(2, 5):
|
||||||
|
x = self.block[i](x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== BigVGAN =====
|
||||||
|
class BigVGAN(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# decoder.0: initial conv [1536, 1024, 7]
|
||||||
|
w0 = state["decoder.0.conv.weight"]
|
||||||
|
ch = w0.shape[0] # 1536
|
||||||
|
self.initial_conv = nn.Module()
|
||||||
|
self.initial_conv.conv = nn.Conv1d(w0.shape[1], ch, w0.shape[2], padding=w0.shape[2]//2)
|
||||||
|
|
||||||
|
# decoder.1-4: upsample blocks
|
||||||
|
# Each: SnakeBeta(ch_in) + ConvTranspose1d(ch_in, ch_out) + 3×AMPBlock(ch_out)
|
||||||
|
self.blocks = nn.ModuleList()
|
||||||
|
for i in range(4):
|
||||||
|
block_idx = i + 1
|
||||||
|
conv_w = state[f"decoder.{block_idx}.block.1.conv.weight"]
|
||||||
|
ch_in = conv_w.shape[0] # input channels for this ConvTranspose1d (= ch from snake)
|
||||||
|
ch_out = conv_w.shape[1] # output channels (Note: ConvTranspose1d weight is [ch_in, ch_out, K])
|
||||||
|
kernel = conv_w.shape[2]
|
||||||
|
# Determine stride and padding from kernel and upsample rate
|
||||||
|
# The upsample rates are [8,5,4,3], kernel = rate*2
|
||||||
|
rate = [8, 5, 4, 3][i]
|
||||||
|
pad = (kernel - rate) // 2
|
||||||
|
|
||||||
|
blk = UpsampleBigVGANBlock(ch_in, ch_out, kernel, rate, pad, block_idx)
|
||||||
|
self.blocks.append(blk)
|
||||||
|
ch = ch_out
|
||||||
|
|
||||||
|
# decoder.5: final SnakeBeta
|
||||||
|
self.final_act = SnakeBeta(ch)
|
||||||
|
# decoder.6: final conv to audio
|
||||||
|
w6 = state["decoder.6.conv.weight"]
|
||||||
|
self.final_conv = nn.Module()
|
||||||
|
self.final_conv.conv = nn.Conv1d(w6.shape[1], w6.shape[0], w6.shape[2], padding=w6.shape[2]//2)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
x = self.initial_conv.conv(x)
|
||||||
|
for blk in self.blocks:
|
||||||
|
for m in blk.block:
|
||||||
|
x = m(x)
|
||||||
|
x = self.final_act(x)
|
||||||
|
x = self.final_conv.conv(x)
|
||||||
|
x = torch.tanh(x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== Build & Load =====
|
||||||
|
print("\n--- Building pre_conv ---")
|
||||||
|
pre_conv = nn.Conv1d(512, 1024, 3, padding=1)
|
||||||
|
pre_conv.weight.data = state["pre_conv.conv.weight"]
|
||||||
|
pre_conv.bias.data = state["pre_conv.conv.bias"]
|
||||||
|
pre_conv.eval()
|
||||||
|
dummy = torch.randn(1, 512, SEQ)
|
||||||
|
with torch.no_grad():
|
||||||
|
pc = pre_conv(dummy)
|
||||||
|
print(f"pre_conv: [1,512,{SEQ}] → {list(pc.shape)}")
|
||||||
|
|
||||||
|
print("\n--- Building pre_transformer ---")
|
||||||
|
pt = PreTransformer()
|
||||||
|
pt_state = {k[len("pre_transformer."):]: v for k, v in state.items() if k.startswith("pre_transformer.")}
|
||||||
|
m, u = pt.load_state_dict(pt_state, strict=False)
|
||||||
|
print(f"pre_transformer load: missing={len(m)}, unexpected={len(u)}")
|
||||||
|
if m: print(f" Missing: {m[:3]}")
|
||||||
|
pt.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
pt_out = pt(pc)
|
||||||
|
print(f"pre_transformer: {list(pc.shape)} → {list(pt_out.shape)}")
|
||||||
|
|
||||||
|
print("\n--- Building upsample ---")
|
||||||
|
up = Upsample()
|
||||||
|
up_map = {}
|
||||||
|
for k, v in state.items():
|
||||||
|
if k.startswith("upsample."):
|
||||||
|
nk = k[len("upsample."):]
|
||||||
|
# nk = "0.0.conv.weight" → conv0.weight, "0.1.dwconv.conv.weight" → cnx0.dwconv.conv.weight
|
||||||
|
parts = nk.split(".", 2)
|
||||||
|
idx = parts[0] # 0 or 1
|
||||||
|
sub = parts[1] # 0 (conv) or 1 (convnext)
|
||||||
|
rest = parts[2] if len(parts) > 2 else ""
|
||||||
|
if sub == "0":
|
||||||
|
# conv transpose: upsample.0.0.conv.weight → conv0.weight (remove ".conv" layer)
|
||||||
|
if rest.startswith("conv."):
|
||||||
|
up_map[f"conv{idx}.{rest[5:]}"] = v
|
||||||
|
else:
|
||||||
|
up_map[f"conv{idx}.{rest}"] = v
|
||||||
|
else:
|
||||||
|
up_map[f"cnx{idx}.{rest}"] = v
|
||||||
|
m, u = up.load_state_dict(up_map, strict=False)
|
||||||
|
print(f"upsample load: missing={len(m)}, unexpected={len(u)}")
|
||||||
|
if m: print(f" Missing: {m[:3]}")
|
||||||
|
up.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
up_out = up(pt_out)
|
||||||
|
print(f"upsample: {list(pt_out.shape)} → {list(up_out.shape)}")
|
||||||
|
|
||||||
|
print("\n--- Building BigVGAN ---")
|
||||||
|
bv = BigVGAN()
|
||||||
|
bv_map = {}
|
||||||
|
for k, v in state.items():
|
||||||
|
if k.startswith("decoder."):
|
||||||
|
nk = k[len("decoder."):]
|
||||||
|
parts = nk.split(".", 1)
|
||||||
|
idx = int(parts[0])
|
||||||
|
rest = parts[1] if len(parts) > 1 else ""
|
||||||
|
if idx == 0:
|
||||||
|
# decoder.0.conv.weight → initial_conv.conv.weight
|
||||||
|
bv_map[f"initial_conv.{rest}"] = v
|
||||||
|
elif 1 <= idx <= 4:
|
||||||
|
# decoder.{i}.block.1.conv.weight → blocks.{i-1}.block.1.weight (ConvTranspose1d, no ".conv")
|
||||||
|
new_rest = rest
|
||||||
|
if rest.startswith("block.1.conv."):
|
||||||
|
# ConvTranspose1d: remove extra ".conv" layer
|
||||||
|
new_rest = "block.1." + rest[len("block.1.conv."):]
|
||||||
|
bv_map[f"blocks.{idx-1}.{new_rest}"] = v
|
||||||
|
elif idx == 5:
|
||||||
|
bv_map[f"final_act.{rest}"] = v
|
||||||
|
elif idx == 6:
|
||||||
|
bv_map[f"final_conv.{rest}"] = v
|
||||||
|
m, u = bv.load_state_dict(bv_map, strict=False)
|
||||||
|
print(f"BigVGAN load: missing={len(m)}, unexpected={len(u)}")
|
||||||
|
if m: print(f" Missing: {m[:5]}")
|
||||||
|
if u: print(f" Unexpected: {u[:5]}")
|
||||||
|
bv.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
audio = bv(up_out)
|
||||||
|
print(f"BigVGAN: {list(up_out.shape)} → {list(audio.shape)}")
|
||||||
|
|
||||||
|
# ===== Export =====
|
||||||
|
print("\n=== Exporting pre_conv ===")
|
||||||
|
torch.onnx.export(pre_conv, dummy, f"{OUT}/pre_conv/model.onnx",
|
||||||
|
input_names=["x"], output_names=["pre_conv_out"], opset_version=17)
|
||||||
|
print(f" {os.path.getsize(f'{OUT}/pre_conv/model.onnx')/1024:.0f} KB")
|
||||||
|
|
||||||
|
class Preprocessor(nn.Module):
|
||||||
|
def __init__(self, pt, up):
|
||||||
|
super().__init__(); self.pt = pt; self.up = up
|
||||||
|
def forward(self, x):
|
||||||
|
return self.up(self.pt(x))
|
||||||
|
|
||||||
|
print("\n=== Exporting preprocessor ===")
|
||||||
|
prep = Preprocessor(pt, up).eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
prep_out = prep(pc)
|
||||||
|
torch.onnx.export(prep, pc, f"{OUT}/preprocessor/model.onnx",
|
||||||
|
input_names=["pre_conv_out"], output_names=["hidden"], opset_version=17)
|
||||||
|
print(f" {os.path.getsize(f'{OUT}/preprocessor/model.onnx')/1024/1024:.1f} MB")
|
||||||
|
|
||||||
|
print("\n=== Exporting conv_decoder ===")
|
||||||
|
torch.onnx.export(bv, up_out, f"{OUT}/conv_decoder/model.onnx",
|
||||||
|
input_names=["hidden"], output_names=["audio"], opset_version=17)
|
||||||
|
print(f" {os.path.getsize(f'{OUT}/conv_decoder/model.onnx')/1024/1024:.1f} MB")
|
||||||
|
|
||||||
|
# ===== Validate =====
|
||||||
|
print("\n=== Validation ===")
|
||||||
|
import onnxruntime as ort
|
||||||
|
for name, inp, ref in [("pre_conv", dummy, pc), ("preprocessor", pc, prep_out), ("conv_decoder", up_out, audio)]:
|
||||||
|
sess = ort.InferenceSession(f"{OUT}/{name}/model.onnx")
|
||||||
|
o = sess.run(None, {sess.get_inputs()[0].name: inp.detach().numpy()})[0]
|
||||||
|
d = np.max(np.abs(o - ref.detach().numpy()))
|
||||||
|
print(f" {name}: max_diff={d:.6f} shape={o.shape}")
|
||||||
|
|
||||||
|
print("\nTotal:")
|
||||||
|
for n in ["pre_conv", "preprocessor", "conv_decoder"]:
|
||||||
|
print(f" {n}: {os.path.getsize(f'{OUT}/{n}/model.onnx')/1024/1024:.1f} MB")
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Export Qwen3-TTS speech decoder V2 to full ONNX for GPU QNN backend.
|
||||||
|
Exact architecture from state dict analysis:
|
||||||
|
pre_conv: Conv1d(512, 1024, kernel=3, pad=1)
|
||||||
|
pre_transformer: 8-layer transformer (dim=512, attn_dim=1024, heads=16, head_dim=64)
|
||||||
|
upsample: 2× ConvTranspose1d(1024,1024) + ConvNeXtV2
|
||||||
|
BigVGAN: 7 blocks with Snake activation, upsample_rates=[8,5,4,3]
|
||||||
|
"""
|
||||||
|
import sys, os, math, warnings
|
||||||
|
sys.path.insert(0, "/opt/Kazeia/qnn_venv/lib/python3.10/site-packages")
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
state = torch.load("/opt/Kazeia/to_delete/models_qnn/qwen3-tts-native/speech_decoder_weights.pt", map_location="cpu")
|
||||||
|
print(f"Loaded {len(state)} tensors")
|
||||||
|
|
||||||
|
OUT = "/tmp/kazeia_decoder_dynt"
|
||||||
|
for d in ["pre_conv", "preprocessor", "conv_decoder"]:
|
||||||
|
os.makedirs(f"{OUT}/{d}", exist_ok=True)
|
||||||
|
|
||||||
|
SEQ = 60
|
||||||
|
|
||||||
|
# ===== RMSNorm =====
|
||||||
|
class RMSNorm(nn.Module):
|
||||||
|
def __init__(self, dim, eps=1e-5):
|
||||||
|
super().__init__()
|
||||||
|
self.weight = nn.Parameter(torch.ones(dim))
|
||||||
|
self.eps = eps
|
||||||
|
def forward(self, x):
|
||||||
|
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) * self.weight
|
||||||
|
|
||||||
|
# ===== DiT Layer =====
|
||||||
|
class DiTLayer(nn.Module):
|
||||||
|
def __init__(self, dim=512, attn_dim=1024, n_heads=16, head_dim=64, ffn_dim=1024, rope_theta=10000.0):
|
||||||
|
super().__init__()
|
||||||
|
self.dim = dim
|
||||||
|
self.n_heads = n_heads
|
||||||
|
self.head_dim = head_dim
|
||||||
|
self.input_layernorm = RMSNorm(dim)
|
||||||
|
self.post_attention_layernorm = RMSNorm(dim)
|
||||||
|
self.self_attn = nn.Module()
|
||||||
|
self.self_attn.q_proj = nn.Linear(dim, attn_dim, bias=False)
|
||||||
|
self.self_attn.k_proj = nn.Linear(dim, attn_dim, bias=False)
|
||||||
|
self.self_attn.v_proj = nn.Linear(dim, attn_dim, bias=False)
|
||||||
|
self.self_attn.o_proj = nn.Linear(attn_dim, dim, bias=False)
|
||||||
|
self.self_attn_layer_scale = nn.Module()
|
||||||
|
self.self_attn_layer_scale.scale = nn.Parameter(torch.ones(dim) * 0.01)
|
||||||
|
self.mlp = nn.Module()
|
||||||
|
self.mlp.gate_proj = nn.Linear(dim, ffn_dim, bias=False)
|
||||||
|
self.mlp.up_proj = nn.Linear(dim, ffn_dim, bias=False)
|
||||||
|
self.mlp.down_proj = nn.Linear(ffn_dim, dim, bias=False)
|
||||||
|
self.mlp_layer_scale = nn.Module()
|
||||||
|
self.mlp_layer_scale.scale = nn.Parameter(torch.ones(dim) * 0.01)
|
||||||
|
# Pre-compute RoPE
|
||||||
|
freqs = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2).float() / head_dim))
|
||||||
|
t = torch.arange(512).float()
|
||||||
|
emb = torch.outer(t, freqs)
|
||||||
|
self.register_buffer("rope_cos", emb.cos())
|
||||||
|
self.register_buffer("rope_sin", emb.sin())
|
||||||
|
|
||||||
|
def apply_rope(self, x, seq_len):
|
||||||
|
cos = self.rope_cos[:seq_len].unsqueeze(0).unsqueeze(0) # [1,1,T,D/2]
|
||||||
|
sin = self.rope_sin[:seq_len].unsqueeze(0).unsqueeze(0)
|
||||||
|
x1, x2 = x[..., ::2], x[..., 1::2]
|
||||||
|
out = torch.stack([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
|
||||||
|
return out.flatten(-2)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
B, T, _ = x.shape
|
||||||
|
h = self.input_layernorm(x)
|
||||||
|
q = self.self_attn.q_proj(h).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
|
||||||
|
k = self.self_attn.k_proj(h).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
|
||||||
|
v = self.self_attn.v_proj(h).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
|
||||||
|
q = self.apply_rope(q, T)
|
||||||
|
k = self.apply_rope(k, T)
|
||||||
|
attn = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
|
||||||
|
mask = torch.triu(torch.full((T, T), float('-inf'), device=x.device), diagonal=1)
|
||||||
|
attn = attn + mask.unsqueeze(0).unsqueeze(0)
|
||||||
|
attn = F.softmax(attn, dim=-1)
|
||||||
|
out = torch.matmul(attn, v).transpose(1, 2).contiguous().view(B, T, -1)
|
||||||
|
x = x + self.self_attn_layer_scale.scale * self.self_attn.o_proj(out)
|
||||||
|
h2 = self.post_attention_layernorm(x)
|
||||||
|
ffn = self.mlp.down_proj(F.silu(self.mlp.gate_proj(h2)) * self.mlp.up_proj(h2))
|
||||||
|
x = x + self.mlp_layer_scale.scale * ffn
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== Pre-transformer =====
|
||||||
|
class PreTransformer(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.input_proj = nn.Linear(1024, 512)
|
||||||
|
self.layers = nn.ModuleList([DiTLayer() for _ in range(8)])
|
||||||
|
self.norm = RMSNorm(512)
|
||||||
|
self.output_proj = nn.Linear(512, 1024)
|
||||||
|
def forward(self, x):
|
||||||
|
x = x.transpose(1, 2) # [B,C,T] → [B,T,C]
|
||||||
|
x = self.input_proj(x)
|
||||||
|
for layer in self.layers:
|
||||||
|
x = layer(x)
|
||||||
|
x = self.norm(x)
|
||||||
|
x = self.output_proj(x)
|
||||||
|
return x.transpose(1, 2) # [B,T,C] → [B,C,T]
|
||||||
|
|
||||||
|
# ===== ConvNeXt-V2 =====
|
||||||
|
class ConvNeXtV2(nn.Module):
|
||||||
|
def __init__(self, dim=1024, int_dim=4096):
|
||||||
|
super().__init__()
|
||||||
|
self.dwconv = nn.Module()
|
||||||
|
self.dwconv.conv = nn.Conv1d(dim, dim, 7, padding=3, groups=dim)
|
||||||
|
self.norm = nn.LayerNorm(dim)
|
||||||
|
self.pwconv1 = nn.Linear(dim, int_dim)
|
||||||
|
self.pwconv2 = nn.Linear(int_dim, dim)
|
||||||
|
self.gamma = nn.Parameter(torch.ones(dim) * 0.01)
|
||||||
|
def forward(self, x):
|
||||||
|
r = x
|
||||||
|
x = self.dwconv.conv(x)
|
||||||
|
x = x.transpose(1, 2)
|
||||||
|
x = self.norm(x)
|
||||||
|
x = F.gelu(self.pwconv1(x))
|
||||||
|
x = self.pwconv2(x)
|
||||||
|
x = (self.gamma * x).transpose(1, 2)
|
||||||
|
return r + x
|
||||||
|
|
||||||
|
# ===== Upsample =====
|
||||||
|
class Upsample(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# upsampling_ratios=[2,2] from config
|
||||||
|
# ConvTranspose1d(1024,1024,kernel=2*ratio,stride=ratio,padding=ratio//2)
|
||||||
|
w0 = state["upsample.0.0.conv.weight"]
|
||||||
|
w1 = state["upsample.1.0.conv.weight"]
|
||||||
|
# For upsampling ratio r: kernel=2*r, stride=r, pad=r//2
|
||||||
|
# But kernel=2 could mean ratio=1 or ratio=2 with pad adjustment
|
||||||
|
# From config: upsampling_ratios=[2,2], so stride=2 for both
|
||||||
|
# kernel=2, stride=2, padding=0 gives exact 2× upsample
|
||||||
|
self.conv0 = nn.ConvTranspose1d(w0.shape[0], w0.shape[1], w0.shape[2], stride=2, padding=0)
|
||||||
|
self.cnx0 = ConvNeXtV2()
|
||||||
|
self.conv1 = nn.ConvTranspose1d(w1.shape[0], w1.shape[1], w1.shape[2], stride=2, padding=0)
|
||||||
|
self.cnx1 = ConvNeXtV2()
|
||||||
|
print(f" Upsample 0: {w0.shape} stride=2")
|
||||||
|
print(f" Upsample 1: {w1.shape} stride=2")
|
||||||
|
def forward(self, x):
|
||||||
|
x = self.conv0(x)
|
||||||
|
x = self.cnx0(x)
|
||||||
|
x = self.conv1(x)
|
||||||
|
x = self.cnx1(x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== SnakeBeta =====
|
||||||
|
class SnakeBeta(nn.Module):
|
||||||
|
def __init__(self, channels):
|
||||||
|
super().__init__()
|
||||||
|
# Stored as [channels] in checkpoint, reshaped to [1,channels,1] in forward
|
||||||
|
self.alpha = nn.Parameter(torch.ones(channels))
|
||||||
|
self.beta = nn.Parameter(torch.ones(channels))
|
||||||
|
def forward(self, x):
|
||||||
|
a = self.alpha.view(1, -1, 1)
|
||||||
|
b = self.beta.view(1, -1, 1)
|
||||||
|
return x + (1.0 / (b + 1e-9)) * (torch.sin(a * x) ** 2)
|
||||||
|
|
||||||
|
# ===== AMPBlock (BigVGAN resblock) =====
|
||||||
|
class AMPBlock(nn.Module):
|
||||||
|
def __init__(self, channels, kernel_size=7):
|
||||||
|
super().__init__()
|
||||||
|
self.act1 = SnakeBeta(channels)
|
||||||
|
self.act2 = SnakeBeta(channels)
|
||||||
|
self.conv1 = nn.Module()
|
||||||
|
self.conv1.conv = nn.Conv1d(channels, channels, kernel_size, padding=kernel_size//2)
|
||||||
|
self.conv2 = nn.Module()
|
||||||
|
self.conv2.conv = nn.Conv1d(channels, channels, kernel_size, padding=1)
|
||||||
|
def forward(self, x):
|
||||||
|
h = self.act1(x)
|
||||||
|
h = self.conv1.conv(h)
|
||||||
|
h = self.act2(h)
|
||||||
|
h = self.conv2.conv(h)
|
||||||
|
return x + h
|
||||||
|
|
||||||
|
# ===== UpsampleBigVGANBlock =====
|
||||||
|
class UpsampleBigVGANBlock(nn.Module):
|
||||||
|
def __init__(self, ch_in, ch_out, kernel, rate, pad, block_idx):
|
||||||
|
super().__init__()
|
||||||
|
self.block = nn.ModuleList()
|
||||||
|
self.block.append(SnakeBeta(ch_in))
|
||||||
|
self.block.append(nn.ConvTranspose1d(ch_in, ch_out, kernel, stride=rate, padding=pad))
|
||||||
|
for j in range(3):
|
||||||
|
amp_w1 = state[f"decoder.{block_idx}.block.{j+2}.conv1.conv.weight"]
|
||||||
|
amp_w2 = state[f"decoder.{block_idx}.block.{j+2}.conv2.conv.weight"]
|
||||||
|
amp = AMPBlock(amp_w1.shape[0], amp_w1.shape[2])
|
||||||
|
amp.conv2.conv = nn.Conv1d(amp_w2.shape[1], amp_w2.shape[0], amp_w2.shape[2], padding=amp_w2.shape[2]//2)
|
||||||
|
self.block.append(amp)
|
||||||
|
def forward(self, x):
|
||||||
|
# block[0] = SnakeBeta, block[1] = ConvTranspose1d, block[2-4] = AMPBlocks
|
||||||
|
x = self.block[0](x)
|
||||||
|
x = self.block[1](x)
|
||||||
|
for i in range(2, 5):
|
||||||
|
x = self.block[i](x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== BigVGAN =====
|
||||||
|
class BigVGAN(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# decoder.0: initial conv [1536, 1024, 7]
|
||||||
|
w0 = state["decoder.0.conv.weight"]
|
||||||
|
ch = w0.shape[0] # 1536
|
||||||
|
self.initial_conv = nn.Module()
|
||||||
|
self.initial_conv.conv = nn.Conv1d(w0.shape[1], ch, w0.shape[2], padding=w0.shape[2]//2)
|
||||||
|
|
||||||
|
# decoder.1-4: upsample blocks
|
||||||
|
# Each: SnakeBeta(ch_in) + ConvTranspose1d(ch_in, ch_out) + 3×AMPBlock(ch_out)
|
||||||
|
self.blocks = nn.ModuleList()
|
||||||
|
for i in range(4):
|
||||||
|
block_idx = i + 1
|
||||||
|
conv_w = state[f"decoder.{block_idx}.block.1.conv.weight"]
|
||||||
|
ch_in = conv_w.shape[0] # input channels for this ConvTranspose1d (= ch from snake)
|
||||||
|
ch_out = conv_w.shape[1] # output channels (Note: ConvTranspose1d weight is [ch_in, ch_out, K])
|
||||||
|
kernel = conv_w.shape[2]
|
||||||
|
# Determine stride and padding from kernel and upsample rate
|
||||||
|
# The upsample rates are [8,5,4,3], kernel = rate*2
|
||||||
|
rate = [8, 5, 4, 3][i]
|
||||||
|
pad = (kernel - rate) // 2
|
||||||
|
|
||||||
|
blk = UpsampleBigVGANBlock(ch_in, ch_out, kernel, rate, pad, block_idx)
|
||||||
|
self.blocks.append(blk)
|
||||||
|
ch = ch_out
|
||||||
|
|
||||||
|
# decoder.5: final SnakeBeta
|
||||||
|
self.final_act = SnakeBeta(ch)
|
||||||
|
# decoder.6: final conv to audio
|
||||||
|
w6 = state["decoder.6.conv.weight"]
|
||||||
|
self.final_conv = nn.Module()
|
||||||
|
self.final_conv.conv = nn.Conv1d(w6.shape[1], w6.shape[0], w6.shape[2], padding=w6.shape[2]//2)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
x = self.initial_conv.conv(x)
|
||||||
|
for blk in self.blocks:
|
||||||
|
for m in blk.block:
|
||||||
|
x = m(x)
|
||||||
|
x = self.final_act(x)
|
||||||
|
x = self.final_conv.conv(x)
|
||||||
|
x = torch.tanh(x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
# ===== Build & Load =====
|
||||||
|
print("\n--- Building pre_conv ---")
|
||||||
|
pre_conv = nn.Conv1d(512, 1024, 3, padding=1)
|
||||||
|
pre_conv.weight.data = state["pre_conv.conv.weight"]
|
||||||
|
pre_conv.bias.data = state["pre_conv.conv.bias"]
|
||||||
|
pre_conv.eval()
|
||||||
|
dummy = torch.randn(1, 512, SEQ)
|
||||||
|
with torch.no_grad():
|
||||||
|
pc = pre_conv(dummy)
|
||||||
|
print(f"pre_conv: [1,512,{SEQ}] → {list(pc.shape)}")
|
||||||
|
|
||||||
|
print("\n--- Building pre_transformer ---")
|
||||||
|
pt = PreTransformer()
|
||||||
|
pt_state = {k[len("pre_transformer."):]: v for k, v in state.items() if k.startswith("pre_transformer.")}
|
||||||
|
m, u = pt.load_state_dict(pt_state, strict=False)
|
||||||
|
print(f"pre_transformer load: missing={len(m)}, unexpected={len(u)}")
|
||||||
|
if m: print(f" Missing: {m[:3]}")
|
||||||
|
pt.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
pt_out = pt(pc)
|
||||||
|
print(f"pre_transformer: {list(pc.shape)} → {list(pt_out.shape)}")
|
||||||
|
|
||||||
|
print("\n--- Building upsample ---")
|
||||||
|
up = Upsample()
|
||||||
|
up_map = {}
|
||||||
|
for k, v in state.items():
|
||||||
|
if k.startswith("upsample."):
|
||||||
|
nk = k[len("upsample."):]
|
||||||
|
# nk = "0.0.conv.weight" → conv0.weight, "0.1.dwconv.conv.weight" → cnx0.dwconv.conv.weight
|
||||||
|
parts = nk.split(".", 2)
|
||||||
|
idx = parts[0] # 0 or 1
|
||||||
|
sub = parts[1] # 0 (conv) or 1 (convnext)
|
||||||
|
rest = parts[2] if len(parts) > 2 else ""
|
||||||
|
if sub == "0":
|
||||||
|
# conv transpose: upsample.0.0.conv.weight → conv0.weight (remove ".conv" layer)
|
||||||
|
if rest.startswith("conv."):
|
||||||
|
up_map[f"conv{idx}.{rest[5:]}"] = v
|
||||||
|
else:
|
||||||
|
up_map[f"conv{idx}.{rest}"] = v
|
||||||
|
else:
|
||||||
|
up_map[f"cnx{idx}.{rest}"] = v
|
||||||
|
m, u = up.load_state_dict(up_map, strict=False)
|
||||||
|
print(f"upsample load: missing={len(m)}, unexpected={len(u)}")
|
||||||
|
if m: print(f" Missing: {m[:3]}")
|
||||||
|
up.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
up_out = up(pt_out)
|
||||||
|
print(f"upsample: {list(pt_out.shape)} → {list(up_out.shape)}")
|
||||||
|
|
||||||
|
print("\n--- Building BigVGAN ---")
|
||||||
|
bv = BigVGAN()
|
||||||
|
bv_map = {}
|
||||||
|
for k, v in state.items():
|
||||||
|
if k.startswith("decoder."):
|
||||||
|
nk = k[len("decoder."):]
|
||||||
|
parts = nk.split(".", 1)
|
||||||
|
idx = int(parts[0])
|
||||||
|
rest = parts[1] if len(parts) > 1 else ""
|
||||||
|
if idx == 0:
|
||||||
|
# decoder.0.conv.weight → initial_conv.conv.weight
|
||||||
|
bv_map[f"initial_conv.{rest}"] = v
|
||||||
|
elif 1 <= idx <= 4:
|
||||||
|
# decoder.{i}.block.1.conv.weight → blocks.{i-1}.block.1.weight (ConvTranspose1d, no ".conv")
|
||||||
|
new_rest = rest
|
||||||
|
if rest.startswith("block.1.conv."):
|
||||||
|
# ConvTranspose1d: remove extra ".conv" layer
|
||||||
|
new_rest = "block.1." + rest[len("block.1.conv."):]
|
||||||
|
bv_map[f"blocks.{idx-1}.{new_rest}"] = v
|
||||||
|
elif idx == 5:
|
||||||
|
bv_map[f"final_act.{rest}"] = v
|
||||||
|
elif idx == 6:
|
||||||
|
bv_map[f"final_conv.{rest}"] = v
|
||||||
|
m, u = bv.load_state_dict(bv_map, strict=False)
|
||||||
|
print(f"BigVGAN load: missing={len(m)}, unexpected={len(u)}")
|
||||||
|
if m: print(f" Missing: {m[:5]}")
|
||||||
|
if u: print(f" Unexpected: {u[:5]}")
|
||||||
|
bv.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
audio = bv(up_out)
|
||||||
|
print(f"BigVGAN: {list(up_out.shape)} → {list(audio.shape)}")
|
||||||
|
|
||||||
|
# ===== Export avec dynamic_axes pour la dim T =====
|
||||||
|
# pre_conv input [1, 512, T] → dim 2 dynamique
|
||||||
|
# pre_conv output [1, 1024, T] → dim 2 dynamique
|
||||||
|
# preprocessor input [1, T, 1024] → dim 1 dynamique (note : preprocessor inclut up qui upsample 2x donc T_out = 2*T_in)
|
||||||
|
# preprocessor output [1, 2T, 1024] → dim 1 dynamique
|
||||||
|
# conv_decoder input [1, 2T, 1024] → dim 1 dynamique
|
||||||
|
# conv_decoder output [1, 1, 2T*960] → dim 2 dynamique (BigVGAN upsample x960)
|
||||||
|
print("\n=== Exporting pre_conv (dyn T) ===")
|
||||||
|
torch.onnx.export(pre_conv, dummy, f"{OUT}/pre_conv/model.onnx",
|
||||||
|
input_names=["x"], output_names=["pre_conv_out"], opset_version=17,
|
||||||
|
dynamic_axes={"x": {2: "T"}, "pre_conv_out": {2: "T"}})
|
||||||
|
print(f" {os.path.getsize(f'{OUT}/pre_conv/model.onnx')/1024:.0f} KB")
|
||||||
|
|
||||||
|
class Preprocessor(nn.Module):
|
||||||
|
def __init__(self, pt, up):
|
||||||
|
super().__init__(); self.pt = pt; self.up = up
|
||||||
|
def forward(self, x):
|
||||||
|
return self.up(self.pt(x))
|
||||||
|
|
||||||
|
print("\n=== Exporting preprocessor (dyn T) ===")
|
||||||
|
prep = Preprocessor(pt, up).eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
prep_out = prep(pc)
|
||||||
|
# input shape [1, 1024, T] (dim 2 = T), output shape [1, 1024, 4T] (upsample x4)
|
||||||
|
torch.onnx.export(prep, pc, f"{OUT}/preprocessor/model.onnx",
|
||||||
|
input_names=["pre_conv_out"], output_names=["hidden"], opset_version=17,
|
||||||
|
dynamic_axes={"pre_conv_out": {2: "T"}, "hidden": {2: "T_up"}})
|
||||||
|
print(f" {os.path.getsize(f'{OUT}/preprocessor/model.onnx')/1024/1024:.1f} MB")
|
||||||
|
|
||||||
|
print("\n=== Exporting conv_decoder (dyn T) ===")
|
||||||
|
# input shape [1, 1024, 4T] (dim 2 = 4T), output shape [1, 1, 4T*480] (BigVGAN upsample x480)
|
||||||
|
torch.onnx.export(bv, up_out, f"{OUT}/conv_decoder/model.onnx",
|
||||||
|
input_names=["hidden"], output_names=["audio"], opset_version=17,
|
||||||
|
dynamic_axes={"hidden": {2: "T_up"}, "audio": {2: "T_audio"}})
|
||||||
|
print(f" {os.path.getsize(f'{OUT}/conv_decoder/model.onnx')/1024/1024:.1f} MB")
|
||||||
|
|
||||||
|
# ===== Validate à T=60 (référence) puis T=128 (dynamic) =====
|
||||||
|
print("\n=== Validation T=60 (réf) ===")
|
||||||
|
import onnxruntime as ort
|
||||||
|
for name, inp, ref in [("pre_conv", dummy, pc), ("preprocessor", pc, prep_out), ("conv_decoder", up_out, audio)]:
|
||||||
|
sess = ort.InferenceSession(f"{OUT}/{name}/model.onnx")
|
||||||
|
o = sess.run(None, {sess.get_inputs()[0].name: inp.detach().numpy()})[0]
|
||||||
|
d = np.max(np.abs(o - ref.detach().numpy()))
|
||||||
|
print(f" {name}: max_diff={d:.6f} shape={o.shape}")
|
||||||
|
|
||||||
|
print("\n=== Test T=128 dynamique (sanity) ===")
|
||||||
|
# Build dummy input avec T=128 et passer dans la chaîne entière
|
||||||
|
T_test = 128
|
||||||
|
d_test = torch.randn(1, 512, T_test)
|
||||||
|
with torch.no_grad():
|
||||||
|
pc_test = pre_conv(d_test)
|
||||||
|
prep_test = Preprocessor(pt, up)(pc_test)
|
||||||
|
bv_test = bv(prep_test)
|
||||||
|
print(f" PyTorch ref: pre_conv={list(pc_test.shape)} prep={list(prep_test.shape)} bv={list(bv_test.shape)}")
|
||||||
|
|
||||||
|
sess_pc = ort.InferenceSession(f"{OUT}/pre_conv/model.onnx")
|
||||||
|
o_pc = sess_pc.run(None, {sess_pc.get_inputs()[0].name: d_test.numpy()})[0]
|
||||||
|
sess_pp = ort.InferenceSession(f"{OUT}/preprocessor/model.onnx")
|
||||||
|
o_pp = sess_pp.run(None, {sess_pp.get_inputs()[0].name: o_pc})[0]
|
||||||
|
sess_cd = ort.InferenceSession(f"{OUT}/conv_decoder/model.onnx")
|
||||||
|
o_cd = sess_cd.run(None, {sess_cd.get_inputs()[0].name: o_pp})[0]
|
||||||
|
print(f" ONNX: pre_conv={o_pc.shape} prep={o_pp.shape} bv={o_cd.shape}")
|
||||||
|
diff_pc = np.max(np.abs(o_pc - pc_test.numpy()))
|
||||||
|
diff_pp = np.max(np.abs(o_pp - prep_test.numpy()))
|
||||||
|
diff_cd = np.max(np.abs(o_cd - bv_test.numpy()))
|
||||||
|
print(f" Max diff vs PyTorch : pre_conv={diff_pc:.6f} prep={diff_pp:.6f} conv_decoder={diff_cd:.6f}")
|
||||||
|
|
||||||
|
print("\nTotal:")
|
||||||
|
for n in ["pre_conv", "preprocessor", "conv_decoder"]:
|
||||||
|
print(f" {n}: {os.path.getsize(f'{OUT}/{n}/model.onnx')/1024/1024:.1f} MB")
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Export Qwen3-TTS talker KV-cache to ONNX with M-RoPE.
|
||||||
|
Following TTS_CALIBRATION_GUIDE.md exactly:
|
||||||
|
Inputs: [emb(1,1,1024), mask(1,1,1,200), cos(1,1,128), sin(1,1,128), 56×kv(1,8,199,128)]
|
||||||
|
Outputs: [hidden(1,1,1024), logits(1,1,3072), 56×kv(1,8,200,128)]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/opt/Kazeia/qnn_venv/lib/python3.10/site-packages")
|
||||||
|
import warnings; warnings.filterwarnings("ignore")
|
||||||
|
import torch, torch.nn as nn, torch.nn.functional as F
|
||||||
|
import numpy as np, os, math
|
||||||
|
|
||||||
|
N_LAYERS = 28; DIM = 1024; N_HEADS = 16; N_KV_HEADS = 8; HEAD_DIM = 128
|
||||||
|
KV_LEN = 199; VOCAB = 3072; FFN_DIM = 3072
|
||||||
|
GQA_GROUPS = N_HEADS // N_KV_HEADS # 2
|
||||||
|
|
||||||
|
print("Loading talker weights...")
|
||||||
|
state = torch.load("/opt/Kazeia/models_qnn/qwen3-tts-export/qwen3_tts_talker.pth",
|
||||||
|
map_location="cpu", weights_only=False)
|
||||||
|
|
||||||
|
class RMSNorm(nn.Module):
|
||||||
|
def __init__(self, dim):
|
||||||
|
super().__init__()
|
||||||
|
self.weight = nn.Parameter(torch.ones(dim))
|
||||||
|
def forward(self, x):
|
||||||
|
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + 1e-6) * self.weight
|
||||||
|
|
||||||
|
def apply_interleaved_rope(x, cos, sin):
|
||||||
|
"""Apply interleaved RoPE: x[..., ::2] and x[..., 1::2] rotated by cos/sin."""
|
||||||
|
# x: [B, heads, 1, head_dim], cos/sin: [1, 1, 128] → use first 64 pairs
|
||||||
|
half = x.shape[-1] // 2
|
||||||
|
cos = cos[..., :half] # [1,1,64]
|
||||||
|
sin = sin[..., :half]
|
||||||
|
x1 = x[..., ::2] # [B, heads, 1, 64]
|
||||||
|
x2 = x[..., 1::2]
|
||||||
|
cos = cos.unsqueeze(1) # [1, 1, 1, 64]
|
||||||
|
sin = sin.unsqueeze(1)
|
||||||
|
o1 = x1 * cos - x2 * sin
|
||||||
|
o2 = x1 * sin + x2 * cos
|
||||||
|
return torch.stack([o1, o2], dim=-1).flatten(-2)
|
||||||
|
|
||||||
|
class TalkerKVWrapper(nn.Module):
|
||||||
|
def __init__(self, sd):
|
||||||
|
super().__init__()
|
||||||
|
self.norm = RMSNorm(DIM)
|
||||||
|
self.norm.weight.data = sd["norm.weight"]
|
||||||
|
self.lm_head = nn.Linear(DIM, VOCAB, bias=False)
|
||||||
|
self.lm_head.weight.data = sd["output.weight"]
|
||||||
|
|
||||||
|
self.layers = nn.ModuleList()
|
||||||
|
for i in range(N_LAYERS):
|
||||||
|
L = nn.Module()
|
||||||
|
L.attn_norm = RMSNorm(DIM); L.attn_norm.weight.data = sd[f"layers.{i}.attention_norm.weight"]
|
||||||
|
L.ffn_norm = RMSNorm(DIM); L.ffn_norm.weight.data = sd[f"layers.{i}.ffn_norm.weight"]
|
||||||
|
L.wq = nn.Linear(DIM, N_HEADS*HEAD_DIM, bias=False); L.wq.weight.data = sd[f"layers.{i}.attention.wq.weight"]
|
||||||
|
L.wk = nn.Linear(DIM, N_KV_HEADS*HEAD_DIM, bias=False); L.wk.weight.data = sd[f"layers.{i}.attention.wk.weight"]
|
||||||
|
L.wv = nn.Linear(DIM, N_KV_HEADS*HEAD_DIM, bias=False); L.wv.weight.data = sd[f"layers.{i}.attention.wv.weight"]
|
||||||
|
L.wo = nn.Linear(N_HEADS*HEAD_DIM, DIM, bias=False); L.wo.weight.data = sd[f"layers.{i}.attention.wo.weight"]
|
||||||
|
L.q_norm = RMSNorm(HEAD_DIM); L.q_norm.weight.data = sd[f"layers.{i}.attention.q_norm_fn.weight"]
|
||||||
|
L.k_norm = RMSNorm(HEAD_DIM); L.k_norm.weight.data = sd[f"layers.{i}.attention.k_norm_fn.weight"]
|
||||||
|
L.w1 = nn.Linear(DIM, FFN_DIM, bias=False); L.w1.weight.data = sd[f"layers.{i}.feed_forward.w1.weight"]
|
||||||
|
L.w2 = nn.Linear(FFN_DIM, DIM, bias=False); L.w2.weight.data = sd[f"layers.{i}.feed_forward.w2.weight"]
|
||||||
|
L.w3 = nn.Linear(DIM, FFN_DIM, bias=False); L.w3.weight.data = sd[f"layers.{i}.feed_forward.w3.weight"]
|
||||||
|
self.layers.append(L)
|
||||||
|
|
||||||
|
def forward(self, emb, mask, cos, sin, *kv_in):
|
||||||
|
"""
|
||||||
|
emb: [1, 1, 1024]
|
||||||
|
mask: [1, 1, 1, 200] (right-aligned, -10000 for masked positions)
|
||||||
|
cos: [1, 1, 128] (M-RoPE cos for current position)
|
||||||
|
sin: [1, 1, 128] (M-RoPE sin for current position)
|
||||||
|
kv_in: 28 × (k[1,8,199,128], v[1,8,199,128])
|
||||||
|
Returns: hidden[1,1,1024], logits[1,1,3072], 28 × (k[1,8,200,128], v[1,8,200,128])
|
||||||
|
"""
|
||||||
|
h = emb # [1, 1, 1024]
|
||||||
|
new_kvs = []
|
||||||
|
|
||||||
|
for i, L in enumerate(self.layers):
|
||||||
|
k_cache = kv_in[i * 2] # [1, 8, KV_LEN, 128]
|
||||||
|
v_cache = kv_in[i * 2 + 1] # [1, 8, KV_LEN, 128]
|
||||||
|
|
||||||
|
h_n = L.attn_norm(h)
|
||||||
|
q = L.wq(h_n).view(1, 1, N_HEADS, HEAD_DIM).transpose(1, 2) # [1,16,1,128]
|
||||||
|
k = L.wk(h_n).view(1, 1, N_KV_HEADS, HEAD_DIM).transpose(1, 2) # [1,8,1,128]
|
||||||
|
v = L.wv(h_n).view(1, 1, N_KV_HEADS, HEAD_DIM).transpose(1, 2) # [1,8,1,128]
|
||||||
|
|
||||||
|
# q_norm, k_norm BEFORE rope
|
||||||
|
q = L.q_norm(q)
|
||||||
|
k = L.k_norm(k)
|
||||||
|
|
||||||
|
# Apply M-RoPE (interleaved)
|
||||||
|
q = apply_interleaved_rope(q, cos, sin)
|
||||||
|
k = apply_interleaved_rope(k, cos, sin)
|
||||||
|
|
||||||
|
# Concat new k,v to cache → [1, 8, 200, 128]
|
||||||
|
k_full = torch.cat([k_cache, k], dim=2) # [1, 8, KV_LEN+1, 128]
|
||||||
|
v_full = torch.cat([v_cache, v], dim=2)
|
||||||
|
|
||||||
|
# GQA expand for attention
|
||||||
|
k_exp = k_full.repeat(1, GQA_GROUPS, 1, 1) # [1, 16, 200, 128]
|
||||||
|
v_exp = v_full.repeat(1, GQA_GROUPS, 1, 1)
|
||||||
|
|
||||||
|
# Attention
|
||||||
|
attn = torch.matmul(q, k_exp.transpose(-2, -1)) / math.sqrt(HEAD_DIM) # [1,16,1,200]
|
||||||
|
attn = attn + mask
|
||||||
|
attn = F.softmax(attn, dim=-1)
|
||||||
|
out = torch.matmul(attn, v_exp) # [1,16,1,128]
|
||||||
|
out = out.transpose(1, 2).contiguous().view(1, 1, N_HEADS * HEAD_DIM) # [1,1,2048]
|
||||||
|
h = h + L.wo(out)
|
||||||
|
|
||||||
|
# FFN (SwiGLU)
|
||||||
|
h_n2 = L.ffn_norm(h)
|
||||||
|
h = h + L.w2(F.silu(L.w1(h_n2)) * L.w3(h_n2))
|
||||||
|
|
||||||
|
new_kvs.extend([k_full, v_full])
|
||||||
|
|
||||||
|
h = self.norm(h)
|
||||||
|
logits = self.lm_head(h) # [1, 1, 3072]
|
||||||
|
|
||||||
|
return (h, logits, *new_kvs)
|
||||||
|
|
||||||
|
print("Building model...")
|
||||||
|
wrapper = TalkerKVWrapper(state)
|
||||||
|
wrapper.eval()
|
||||||
|
|
||||||
|
# Test with dummy inputs
|
||||||
|
print("Testing...")
|
||||||
|
dummy_emb = torch.randn(1, 1, DIM)
|
||||||
|
dummy_mask = torch.full((1, 1, 1, KV_LEN + 1), -10000.0)
|
||||||
|
dummy_mask[0, 0, 0, -1] = 0.0 # only current position visible
|
||||||
|
dummy_cos = torch.ones(1, 1, HEAD_DIM)
|
||||||
|
dummy_sin = torch.zeros(1, 1, HEAD_DIM)
|
||||||
|
kv_init = []
|
||||||
|
for _ in range(N_LAYERS):
|
||||||
|
kv_init.append(torch.zeros(1, N_KV_HEADS, KV_LEN, HEAD_DIM)) # k
|
||||||
|
kv_init.append(torch.zeros(1, N_KV_HEADS, KV_LEN, HEAD_DIM)) # v
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
out = wrapper(dummy_emb, dummy_mask, dummy_cos, dummy_sin, *kv_init)
|
||||||
|
|
||||||
|
hidden = out[0]
|
||||||
|
logits = out[1]
|
||||||
|
print(f"hidden: {hidden.shape}, logits: {logits.shape}")
|
||||||
|
print(f"new_k[0]: {out[2].shape}, new_v[0]: {out[3].shape}")
|
||||||
|
|
||||||
|
# Export ONNX
|
||||||
|
OUT = "/opt/Kazeia/models_qnn/talker_kv_cpu_mrope"
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
input_names = ["inputs_embeds", "attention_mask", "cos", "sin"]
|
||||||
|
for i in range(N_LAYERS):
|
||||||
|
input_names.extend([f"k_{i}_in", f"v_{i}_in"])
|
||||||
|
|
||||||
|
output_names = ["hidden", "logits"]
|
||||||
|
for i in range(N_LAYERS):
|
||||||
|
output_names.extend([f"k_{i}_out", f"v_{i}_out"])
|
||||||
|
|
||||||
|
all_inputs = (dummy_emb, dummy_mask, dummy_cos, dummy_sin, *kv_init)
|
||||||
|
|
||||||
|
print(f"\nExporting ONNX ({len(input_names)} inputs, {len(output_names)} outputs)...")
|
||||||
|
torch.onnx.export(
|
||||||
|
wrapper, all_inputs,
|
||||||
|
f"{OUT}/model.onnx",
|
||||||
|
input_names=input_names,
|
||||||
|
output_names=output_names,
|
||||||
|
opset_version=17,
|
||||||
|
)
|
||||||
|
sz = os.path.getsize(f"{OUT}/model.onnx")
|
||||||
|
print(f"Saved: {OUT}/model.onnx ({sz/1024/1024:.0f} MB)")
|
||||||
|
|
||||||
|
# Pre-compute M-RoPE cos/sin for all 200 positions and save
|
||||||
|
# For TTS, all 3 mrope axes use the same position, so M-RoPE = standard RoPE
|
||||||
|
# inv_freq = 1 / (theta^(2i/128)) for i in 0..63
|
||||||
|
print("\nPre-computing RoPE tables (standard, theta=1e6)...")
|
||||||
|
theta = 1000000.0
|
||||||
|
inv_freq = 1.0 / (theta ** (np.arange(0, HEAD_DIM, 2, dtype=np.float32) / HEAD_DIM)) # [64]
|
||||||
|
positions = np.arange(KV_LEN + 1, dtype=np.float32) # [200]
|
||||||
|
angles = np.outer(positions, inv_freq) # [200, 64]
|
||||||
|
rope_cos = np.cos(angles).astype(np.float32) # [200, 64]
|
||||||
|
rope_sin = np.sin(angles).astype(np.float32)
|
||||||
|
# Expand to head_dim=128 for interleaved rope: each freq pair applies to (x[2i], x[2i+1])
|
||||||
|
rope_cos_full = np.repeat(rope_cos, 2, axis=1) # [200, 128]
|
||||||
|
rope_sin_full = np.repeat(rope_sin, 2, axis=1)
|
||||||
|
np.save(f"{OUT}/talker_rotary_cos.npy", rope_cos_full)
|
||||||
|
np.save(f"{OUT}/talker_rotary_sin.npy", rope_sin_full)
|
||||||
|
print(f"Rotary tables: {rope_cos_full.shape} saved")
|
||||||
|
|
||||||
|
# Validate: run a quick prefill step and check output
|
||||||
|
print("\n=== Validation ===")
|
||||||
|
import onnxruntime as ort
|
||||||
|
sess = ort.InferenceSession(f"{OUT}/model.onnx")
|
||||||
|
print(f"ONNX inputs: {[i.name for i in sess.get_inputs()[:4]]}...")
|
||||||
|
|
||||||
|
mask = np.full((1, 1, 1, KV_LEN + 1), -10000.0, dtype=np.float32)
|
||||||
|
mask[0, 0, 0, -1] = 0.0
|
||||||
|
cos_0 = rope_cos_full[0:1].reshape(1, 1, HEAD_DIM)
|
||||||
|
sin_0 = rope_sin_full[0:1].reshape(1, 1, HEAD_DIM)
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
"inputs_embeds": np.random.randn(1, 1, DIM).astype(np.float32),
|
||||||
|
"attention_mask": mask,
|
||||||
|
"cos": cos_0,
|
||||||
|
"sin": sin_0,
|
||||||
|
}
|
||||||
|
for i in range(N_LAYERS):
|
||||||
|
inputs[f"k_{i}_in"] = np.zeros((1, N_KV_HEADS, KV_LEN, HEAD_DIM), dtype=np.float32)
|
||||||
|
inputs[f"v_{i}_in"] = np.zeros((1, N_KV_HEADS, KV_LEN, HEAD_DIM), dtype=np.float32)
|
||||||
|
|
||||||
|
out = sess.run(None, inputs)
|
||||||
|
print(f"ONNX hidden: {out[0].shape}, logits: {out[1].shape}")
|
||||||
|
print(f"ONNX k_0_out: {out[2].shape}")
|
||||||
|
print("Validation OK!")
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract embedding tables from Qwen3-TTS for the Android app."""
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
|
||||||
|
OUT = "/opt/Kazeia/tts_npy_exports"
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
NATIVE = "/opt/Kazeia/models_qnn/qwen3-tts-native"
|
||||||
|
TALKER_HF = "/opt/Kazeia/models_qnn/talker_hf"
|
||||||
|
|
||||||
|
# 1. Text embeddings + projection
|
||||||
|
print("=== text_components.pt ===")
|
||||||
|
tc = torch.load(f"{NATIVE}/text_components.pt", map_location="cpu", weights_only=False)
|
||||||
|
text_emb_dict = tc['text_embedding'] # OrderedDict
|
||||||
|
text_proj_dict = tc['text_projection'] # OrderedDict
|
||||||
|
|
||||||
|
print("text_embedding keys:", list(text_emb_dict.keys()))
|
||||||
|
print("text_projection keys:", list(text_proj_dict.keys()))
|
||||||
|
|
||||||
|
# text_embedding.weight: [151936, 2048] — full LLM vocab, but we only need first 1050 tokens
|
||||||
|
text_emb_w = text_emb_dict['weight'] # [151936, 2048]
|
||||||
|
print(f"text_embedding.weight: {text_emb_w.shape}")
|
||||||
|
|
||||||
|
# Only keep the first 1050 text tokens (CODEC_OFFSET in the engine)
|
||||||
|
text_emb_w = text_emb_w[:1050] # [1050, 2048]
|
||||||
|
print(f"text_embedding (trimmed): {text_emb_w.shape}")
|
||||||
|
|
||||||
|
# text_projection is a 2-layer MLP: fc1(2048→1024) + GELU + fc2(1024→1024)
|
||||||
|
fc1_w = text_proj_dict['linear_fc1.weight'] # [hidden, 2048]
|
||||||
|
fc1_b = text_proj_dict['linear_fc1.bias']
|
||||||
|
fc2_w = text_proj_dict['linear_fc2.weight'] # [1024, hidden]
|
||||||
|
fc2_b = text_proj_dict['linear_fc2.bias']
|
||||||
|
print(f"text_projection: fc1={fc1_w.shape}, fc2={fc2_w.shape}")
|
||||||
|
|
||||||
|
# Apply: x = fc2(silu(fc1(text_emb))) — hidden_act = "silu" from config.json
|
||||||
|
x = text_emb_w.float() @ fc1_w.float().T + fc1_b.float()
|
||||||
|
x = torch.nn.functional.silu(x)
|
||||||
|
text_projected = x @ fc2_w.float().T + fc2_b.float()
|
||||||
|
print(f"text_embeds_projected: {text_projected.shape}")
|
||||||
|
np.save(f"{OUT}/text_embeds_projected.npy", text_projected.numpy())
|
||||||
|
|
||||||
|
# 2. Codec embedding from talker HF safetensors
|
||||||
|
print("\n=== talker safetensors ===")
|
||||||
|
from safetensors.torch import load_file
|
||||||
|
talker_st = load_file(f"{TALKER_HF}/model.safetensors")
|
||||||
|
# Find the embed_tokens / codec_embedding
|
||||||
|
for k in sorted(talker_st.keys()):
|
||||||
|
if 'embed' in k.lower():
|
||||||
|
print(f" {k}: {talker_st[k].shape}")
|
||||||
|
|
||||||
|
# The talker's token embedding is the codec embedding
|
||||||
|
codec_key = None
|
||||||
|
for k in talker_st.keys():
|
||||||
|
if 'embed_tokens' in k or 'codec_embedding' in k:
|
||||||
|
codec_key = k
|
||||||
|
break
|
||||||
|
if codec_key:
|
||||||
|
codec_emb = talker_st[codec_key].float().numpy()
|
||||||
|
print(f"codec_embedding ({codec_key}): {codec_emb.shape}")
|
||||||
|
np.save(f"{OUT}/codec_embedding.npy", codec_emb)
|
||||||
|
else:
|
||||||
|
print("WARNING: codec_embedding not found in talker safetensors")
|
||||||
|
# Fallback: try lm_head transposed
|
||||||
|
for k in talker_st.keys():
|
||||||
|
print(f" {k}: {talker_st[k].shape}")
|
||||||
|
|
||||||
|
# 3. TTS special embeddings [3, 1024] = bos(2149), eos(2150), pad(2148)
|
||||||
|
# These are TEXT token IDs that go through text_embedding + text_projection (SiLU MLP)
|
||||||
|
# NOT codec_embedding! The IDs are in the LLM text vocab space (151936)
|
||||||
|
text_emb_full = text_emb_dict['weight'] # [151936, 2048] — full vocab
|
||||||
|
special_ids = [2149, 2150, 2148] # bos, eos, pad
|
||||||
|
special_text = text_emb_full[special_ids].float() # [3, 2048]
|
||||||
|
# Apply text_projection: fc1 + SiLU + fc2
|
||||||
|
h = special_text @ fc1_w.float().T + fc1_b.float()
|
||||||
|
h = torch.nn.functional.silu(h)
|
||||||
|
special_projected = (h @ fc2_w.float().T + fc2_b.float()).numpy()
|
||||||
|
np.save(f"{OUT}/tts_special_embeds.npy", special_projected)
|
||||||
|
print(f"tts_special_embeds: {special_projected.shape} (projected via text_projection SiLU)")
|
||||||
|
|
||||||
|
# 4. Code predictor embeddings — 15 codebook embedding tables [15, 2048, 1024]
|
||||||
|
print("\n=== code_predictor_weights.pt ===")
|
||||||
|
cp = torch.load(f"{NATIVE}/code_predictor_weights.pt", map_location="cpu", weights_only=False)
|
||||||
|
cp_embs = []
|
||||||
|
for i in range(15):
|
||||||
|
key = f"model.codec_embedding.{i}.weight"
|
||||||
|
if key in cp:
|
||||||
|
cp_embs.append(cp[key].float().numpy())
|
||||||
|
if cp_embs:
|
||||||
|
cp_all = np.stack(cp_embs) # [15, 2048, 1024]
|
||||||
|
np.save(f"{OUT}/code_predictor_embeddings.npy", cp_all)
|
||||||
|
print(f"code_predictor_embeddings: {cp_all.shape}")
|
||||||
|
|
||||||
|
# 5. VQ codebooks — extract actual codebook vectors from embedding_sum / cluster_usage
|
||||||
|
print("\n=== speech_decoder_weights.pt (VQ codebooks) ===")
|
||||||
|
sd = torch.load(f"{NATIVE}/speech_decoder_weights.pt", map_location="cpu", weights_only=False)
|
||||||
|
|
||||||
|
# First codebook (rvq_first) + 15 rest codebooks (rvq_rest)
|
||||||
|
codebooks = []
|
||||||
|
# rvq_first
|
||||||
|
usage = sd['quantizer.rvq_first.vq.layers.0._codebook.cluster_usage']
|
||||||
|
emb_sum = sd['quantizer.rvq_first.vq.layers.0._codebook.embedding_sum']
|
||||||
|
# actual codebook = embedding_sum / cluster_usage (EMA-style)
|
||||||
|
cb = (emb_sum / usage.unsqueeze(1).clamp(min=1)).float().numpy()
|
||||||
|
codebooks.append(cb)
|
||||||
|
print(f" rvq_first codebook: {cb.shape}")
|
||||||
|
|
||||||
|
# rvq_rest has 15 layers (indices 0-14)
|
||||||
|
for i in range(15):
|
||||||
|
usage = sd[f'quantizer.rvq_rest.vq.layers.{i}._codebook.cluster_usage']
|
||||||
|
emb_sum = sd[f'quantizer.rvq_rest.vq.layers.{i}._codebook.embedding_sum']
|
||||||
|
cb = (emb_sum / usage.unsqueeze(1).clamp(min=1)).float().numpy()
|
||||||
|
codebooks.append(cb)
|
||||||
|
|
||||||
|
vq_all = np.stack(codebooks) # [16, 2048, 256]
|
||||||
|
np.save(f"{OUT}/vq_codebooks.npy", vq_all)
|
||||||
|
print(f"vq_codebooks: {vq_all.shape} (16 codebooks × 2048 entries × 256 dim)")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print(f"\n=== Output: {OUT} ===")
|
||||||
|
for f in sorted(os.listdir(OUT)):
|
||||||
|
sz = os.path.getsize(f"{OUT}/{f}")
|
||||||
|
print(f" {f}: {sz/1024/1024:.1f} MB")
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate talker codec tokens on PC using the full Python pipeline,
|
||||||
|
then save them for the tablet to decode (CP + VQ + Decoder).
|
||||||
|
|
||||||
|
This bypasses the broken talker ONNX/GGUF export and proves
|
||||||
|
that the rest of the tablet pipeline (CP + decoder) works.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- talker_output.bin: binary file with cb0 tokens + hidden states
|
||||||
|
Format: int32 n_tokens, then for each token:
|
||||||
|
int32 cb0, float32[1024] hidden_state
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/opt/Kazeia/qnn_venv/lib/python3.10/site-packages")
|
||||||
|
import warnings; warnings.filterwarnings("ignore")
|
||||||
|
import torch, numpy as np, struct
|
||||||
|
|
||||||
|
model_path = "/home/alf/.cache/huggingface/hub/models--Qwen--Qwen3-TTS-12Hz-0.6B-Base/snapshots/5d83992436eae1d760afd27aff78a71d676296fc"
|
||||||
|
|
||||||
|
from qwen_tts.core.models.modeling_qwen3_tts import Qwen3TTSForConditionalGeneration
|
||||||
|
from transformers import AutoTokenizer
|
||||||
|
|
||||||
|
print("Loading model...")
|
||||||
|
model = Qwen3TTSForConditionalGeneration.from_pretrained(model_path, dtype=torch.float32, device_map='cpu')
|
||||||
|
model.eval()
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(model_path)
|
||||||
|
spk = torch.from_numpy(np.load('models_qnn/qwen3-tts-embeddings/damien_spk_embedding.npy')).float()
|
||||||
|
|
||||||
|
TEXT = "Bonjour, je m'appelle Kazeia, je suis encore en phase de développement."
|
||||||
|
|
||||||
|
# Hook to capture hidden states from talker
|
||||||
|
hidden_states = []
|
||||||
|
cb0_tokens = []
|
||||||
|
|
||||||
|
orig_forward = model.talker.model.forward
|
||||||
|
|
||||||
|
def capture_forward(*args, **kwargs):
|
||||||
|
result = orig_forward(*args, **kwargs)
|
||||||
|
# result is a BaseModelOutputWithPast — hidden states are in last_hidden_state
|
||||||
|
if hasattr(result, 'last_hidden_state'):
|
||||||
|
h = result.last_hidden_state
|
||||||
|
if h.shape[1] == 1: # decode step
|
||||||
|
hidden_states.append(h.detach().squeeze().cpu().numpy())
|
||||||
|
return result
|
||||||
|
|
||||||
|
model.talker.model.forward = capture_forward
|
||||||
|
|
||||||
|
# Also hook codec_head to capture cb0 tokens
|
||||||
|
orig_codec_head = model.talker.codec_head
|
||||||
|
|
||||||
|
class HookedCodecHead(torch.nn.Module):
|
||||||
|
def __init__(self, head):
|
||||||
|
super().__init__()
|
||||||
|
self.head = head
|
||||||
|
def forward(self, x):
|
||||||
|
logits = self.head(x)
|
||||||
|
return logits
|
||||||
|
|
||||||
|
model.talker.codec_head = HookedCodecHead(orig_codec_head)
|
||||||
|
|
||||||
|
prompt = f"<|im_start|>assistant\n{TEXT}<|endoftext|>\n<|im_start|>assistant\n"
|
||||||
|
input_ids = tokenizer(prompt, return_tensors="pt").input_ids
|
||||||
|
|
||||||
|
print(f"Generating: '{TEXT}'")
|
||||||
|
print(f"Input tokens: {input_ids.shape[1]}")
|
||||||
|
|
||||||
|
torch.manual_seed(42)
|
||||||
|
with torch.no_grad():
|
||||||
|
result = model.generate(
|
||||||
|
input_ids=[input_ids],
|
||||||
|
languages=["french"],
|
||||||
|
speakers=[""],
|
||||||
|
voice_clone_prompt={
|
||||||
|
"x_vector_only_mode": [True],
|
||||||
|
"icl_mode": [False],
|
||||||
|
"ref_code": None,
|
||||||
|
"ref_spk_embedding": [spk],
|
||||||
|
"x_vector": [spk],
|
||||||
|
},
|
||||||
|
do_sample=True,
|
||||||
|
temperature=0.9,
|
||||||
|
top_k=50,
|
||||||
|
repetition_penalty=1.05,
|
||||||
|
max_new_tokens=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
# result is a list of [audio_codes] tensors
|
||||||
|
codes = result[0][0] # [n_tokens, 16] (16 codebooks)
|
||||||
|
n_tokens = codes.shape[0]
|
||||||
|
print(f"Generated {n_tokens} tokens × {codes.shape[1]} codebooks")
|
||||||
|
print(f"Duration: {n_tokens/12:.2f}s")
|
||||||
|
print(f"First 5 cb0: {codes[:5, 0].tolist()}")
|
||||||
|
print(f"Hidden states captured: {len(hidden_states)}")
|
||||||
|
|
||||||
|
# Save ALL 16 codebooks directly — tablet doesn't need to run CP at all!
|
||||||
|
# Format: int32 n_tokens, int32 n_codebooks, then [n_tokens × n_codebooks] int32
|
||||||
|
OUT = "/opt/Kazeia/tablet_codes.bin"
|
||||||
|
with open(OUT, "wb") as f:
|
||||||
|
f.write(struct.pack("<ii", n_tokens, codes.shape[1]))
|
||||||
|
for t in range(n_tokens):
|
||||||
|
for cb in range(codes.shape[1]):
|
||||||
|
f.write(struct.pack("<i", int(codes[t, cb].item())))
|
||||||
|
|
||||||
|
print(f"\nSaved {n_tokens} × {codes.shape[1]} codes to {OUT}")
|
||||||
|
print(f"File size: {4*2 + n_tokens*codes.shape[1]*4} bytes")
|
||||||
|
|
||||||
|
# Also save as numpy for easy loading
|
||||||
|
np.save("/opt/Kazeia/tablet_codes.npy", codes.numpy())
|
||||||
|
|
||||||
|
# Decode locally for comparison WAV
|
||||||
|
print("\nDecoding locally...")
|
||||||
|
speech_tokenizer_path = "/home/alf/.cache/huggingface/hub/models--Qwen--Qwen3-TTS-Tokenizer-12Hz/snapshots/7dd38ad4e9bad454aae9cd937d0cd577604fe229"
|
||||||
|
from qwen_tts.core.tokenizer_25hz.modeling_qwen3_tts_tokenizer_v1 import Qwen3TTSTokenizerV1Config, Qwen3TTSTokenizerV1Model
|
||||||
|
import json
|
||||||
|
from safetensors.torch import load_file
|
||||||
|
|
||||||
|
with open(f"{speech_tokenizer_path}/config.json") as f:
|
||||||
|
tok_cfg = json.load(f)
|
||||||
|
tok_config = Qwen3TTSTokenizerV1Config(**tok_cfg)
|
||||||
|
tok_model = Qwen3TTSTokenizerV1Model(tok_config)
|
||||||
|
tok_state = load_file(f"{speech_tokenizer_path}/model.safetensors")
|
||||||
|
tok_model.load_state_dict(tok_state, strict=False)
|
||||||
|
tok_model.eval()
|
||||||
|
|
||||||
|
# Decode codes → audio
|
||||||
|
with torch.no_grad():
|
||||||
|
audio_codes = codes.unsqueeze(0).long() # [1, n_tokens, 16]
|
||||||
|
# The decode method needs xvectors and ref_mels, but for x_vector_only_mode
|
||||||
|
# we can try direct decode
|
||||||
|
# Actually, the tokenizer decode takes audio_codes and produces waveform
|
||||||
|
dummy_xvec = spk.unsqueeze(0)
|
||||||
|
dummy_mel = torch.zeros(1, 1, 128)
|
||||||
|
output = tok_model.decode(audio_codes[:, :, 0], dummy_xvec, dummy_mel)
|
||||||
|
|
||||||
|
print(f"Decode output type: {type(output)}")
|
||||||
|
if hasattr(output, 'audio_values') or isinstance(output, tuple):
|
||||||
|
audio = output[0] if isinstance(output, tuple) else output.audio_values
|
||||||
|
if isinstance(audio, list):
|
||||||
|
audio = audio[0]
|
||||||
|
audio_np = audio.numpy() if hasattr(audio, 'numpy') else np.array(audio)
|
||||||
|
print(f"Audio: {audio_np.shape}, range [{audio_np.min():.3f}, {audio_np.max():.3f}]")
|
||||||
|
import soundfile as sf
|
||||||
|
sf.write("/opt/Kazeia/kazeia_tablet_codes_decoded_pc.wav", audio_np, 24000)
|
||||||
|
print("Saved decoded WAV")
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a WAV file using Qwen3-TTS with Damien's voice cloning."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/opt/Kazeia/qnn_venv/lib/python3.10/site-packages")
|
||||||
|
import warnings; warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
import soundfile as sf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
MODEL = "/home/alf/.cache/huggingface/hub/models--Qwen--Qwen3-TTS-12Hz-0.6B-Base/snapshots/5d83992436eae1d760afd27aff78a71d676296fc"
|
||||||
|
VOICE = "/opt/Kazeia/voix/damien_15s_24k.wav"
|
||||||
|
TEXT = "Bonjour, je m'appelle Kazeia, je suis encore en phase de développement."
|
||||||
|
OUTPUT = "/opt/Kazeia/kazeia_damien_pc.wav"
|
||||||
|
|
||||||
|
print("Loading model...")
|
||||||
|
tts = Qwen3TTSModel.from_pretrained(MODEL, local_files_only=True, device_map="cpu")
|
||||||
|
|
||||||
|
# Also generate phrase_embeds.bin for tablet use
|
||||||
|
import torch, struct
|
||||||
|
tokenizer = tts.processor.tokenizer
|
||||||
|
talker = tts.model.talker
|
||||||
|
|
||||||
|
ids = tokenizer.encode(TEXT, add_special_tokens=False)
|
||||||
|
print(f"Tokens ({len(ids)}): {ids}")
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
raw = talker.model.text_embedding(torch.tensor(ids))
|
||||||
|
projected = talker.text_projection(raw)
|
||||||
|
|
||||||
|
with open("/tmp/phrase_embeds.bin", "wb") as f:
|
||||||
|
f.write(struct.pack("<i", len(ids)))
|
||||||
|
for i in range(len(ids)):
|
||||||
|
f.write(projected[i].numpy().astype(np.float32).tobytes())
|
||||||
|
print(f"phrase_embeds.bin: {len(ids)} tokens saved")
|
||||||
|
|
||||||
|
# Generate speech
|
||||||
|
print(f"Generating: '{TEXT}'")
|
||||||
|
print(f"Voice: {VOICE}")
|
||||||
|
audio_list, sr = tts.generate_voice_clone(
|
||||||
|
text=TEXT,
|
||||||
|
ref_audio=VOICE,
|
||||||
|
language="french",
|
||||||
|
x_vector_only_mode=True,
|
||||||
|
non_streaming_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio = audio_list[0]
|
||||||
|
print(f"Audio: {len(audio)} samples, {len(audio)/sr:.2f}s, SR={sr}")
|
||||||
|
|
||||||
|
# Check audio is not silent
|
||||||
|
rms = np.sqrt(np.mean(audio**2))
|
||||||
|
print(f"RMS: {rms:.4f} (should be > 0.01)")
|
||||||
|
|
||||||
|
sf.write(OUTPUT, audio, sr)
|
||||||
|
print(f"Saved: {OUTPUT}")
|
||||||
Loading…
Reference in New Issue