Initial commit: Kazeia TTS pipeline on NPU via ExecuTorch

Full Qwen3-TTS-0.6B pipeline running on Snapdragon 8 Elite NPU:
  - Talker (28L) and Code Predictor (5L) as .pte on QNN HTP fp16
  - JNI integration, no root required
  - Validated audio quality: RTF 3.9

  Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-04-09 08:42:11 +02:00
commit 389ffa7c61
95 changed files with 22279 additions and 0 deletions

85
.gitignore vendored Normal file
View File

@ -0,0 +1,85 @@
# ============================================
# Kazeia .gitignore — code only, no binaries
# ============================================
# === Large binary files ===
*.so
*.so.*
*.so.bak
*.jar
*.aar
*.pte
*.gguf
*.onnx
*.bin
*.npy
*.wav
*.apk
# === Build outputs ===
kazeia-android/app/build/
kazeia-android/build/
kazeia-android/.gradle/
kazeia-android/local.properties
kazeia-android/extracted/
kazeia-android/app/.cxx/
kazeia-android/unityLibrary/
# === Python environments ===
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/
*.iml
# === Compiled binaries ===
cp_et_test_client
llama.cpp/build*/
# === OS files ===
.DS_Store
Thumbs.db
# === Temporary ===
*.tmp
*.log
*.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

2
AI_HUB_QUALCOMM.md Normal file
View File

@ -0,0 +1,2 @@
E-Mail: support@kazeia.com
API Token: vlwls2lyair3f15c98il8g7xsm8g6377zuj2mthc

193
ARCHITECTURE_PIPELINE.md Normal file
View File

@ -0,0 +1,193 @@
# Architecture Pipeline Kazeia
*Version 2.0 — 28 mars 2026*
## Principe
Le pipeline Kazeia est **modulaire** : STT et TTS sont indépendants et échangent uniquement du texte avec une chaîne de processeurs pluggables.
```
┌─────────┐ ┌──────────────────────────┐ ┌─────────┐
│ STT │────→│ PROCESSOR CHAIN │────→│ TTS │
│(Whisper)│ │ │ │(Android/│
│ │ │ ┌──────────────────┐ │ │Chatterbox│
│ Audio │ │ │ Voice Commands │ │ │ │
│ → Text │ │ └────────┬─────────┘ │ │ Text │
│ │ │ ┌────────▼─────────┐ │ │ → Audio│
│ │ │ │ LLM (Qwen3 NPU) │ │ │ │
│ │ │ └────────┬─────────┘ │ │ │
│ │ │ ┌────────▼─────────┐ │ │ │
│ │ │ │ (Future: RAG) │ │ │ │
│ │ │ └────────┬─────────┘ │ │ │
│ │ │ ┌────────▼─────────┐ │ │ │
│ │ │ │ (Future: Emotion)│ │ │ │
│ │ │ └──────────────────┘ │ │ │
└─────────┘ └──────────────────────────┘ └─────────┘
```
## Interfaces
### SttEngine (Speech-to-Text)
```kotlin
interface SttEngine {
suspend fun load(modelPath: String?)
fun isLoaded(): Boolean
suspend fun transcribe(audioData: ShortArray, language: String): TranscriptionResult
fun release()
}
```
Implémentations :
| Classe | Backend | Latence | NPU |
|--------|---------|---------|-----|
| `WhisperSttEngine` | whisper.cpp CPU | ~1500ms | Non |
| `WhisperNpuSttEngine` | ExecuTorch QNN | ~50ms* | Oui |
| `AndroidSttEngine` | Google SpeechRecognizer | ~500ms | Non (cloud) |
### TtsEngine (Text-to-Speech)
```kotlin
interface TtsEngine {
suspend fun load(modelPath: String?, voiceId: String?)
fun isLoaded(): Boolean
suspend fun synthesizeAndPlay(text: String, language: String, onStart: (() -> Unit)?, onComplete: (() -> Unit)?)
fun stop()
fun release()
}
```
Implémentations :
| Classe | Backend | Latence | Clonage voix |
|--------|---------|---------|-------------|
| `AndroidTtsEngine` | Google TTS | ~200ms | Non |
| `ChatterboxTtsEngine` | ONNX CPU/NPU | ~3-10s | Oui |
### MessageProcessor (Middleware)
```kotlin
interface MessageProcessor {
val name: String
suspend fun initialize()
fun isReady(): Boolean
suspend fun process(input: String, context: ConversationContext): ProcessorResult
fun release()
}
```
Implémentations :
| Classe | Rôle | Priorité |
|--------|------|----------|
| `VoiceCommandProcessor2` | Intercepte les commandes vocales | 1 (premier) |
| `LlmProcessor` | Génère des réponses via LLM | 2 |
| `EchoProcessor` | Répète l'input (fallback/test) | 3 |
| *(Future)* `EmotionProcessor` | Détecte l'émotion de la voix | 1.5 |
| *(Future)* `RagProcessor` | Enrichit avec des documents | 1.5 |
| *(Future)* `DiarizationProcessor` | Identifie le locuteur | 1 |
### Chaîne de traitement
Les processeurs sont exécutés **dans l'ordre**. Le premier qui retourne `shouldContinueChain = false` termine la chaîne.
```
Input: "Bonjour, comment vas-tu ?"
→ VoiceCommandProcessor2: pas de commande → continue
→ LlmProcessor: "Je vais bien, comment puis-je t'aider ?" → done
Output: "Je vais bien, comment puis-je t'aider ?"
Input: "stop"
→ VoiceCommandProcessor2: commande STOP_LISTENING → done (shouldSpeak=false)
Output: (arrête l'écoute)
```
## Ajouter un nouveau processeur
```kotlin
class MonNouveauProcessor : MessageProcessor {
override val name = "MonProcesseur"
override suspend fun process(input: String, context: ConversationContext): ProcessorResult {
// Traiter l'input
val enrichedInput = "[$emotion] $input"
return ProcessorResult(
responseText = "",
shouldContinueChain = true, // passe au processeur suivant
metadata = mapOf("emotion" to "triste")
)
}
}
// Ajout au pipeline
pipeline.addProcessor(MonNouveauProcessor())
```
## ConversationContext
Le contexte est partagé entre tous les processeurs :
```kotlin
data class ConversationContext(
val history: List<ChatMessage>, // historique conversation
val metadata: MutableMap<String, Any>, // données partagées
val language: String, // "fr"
val speakerId: String?, // identification locuteur
val emotion: String?, // émotion détectée
val sessionId: String // identifiant session
)
```
Les processeurs peuvent lire et écrire dans `metadata` pour communiquer entre eux.
## Performances actuelles
| Composant | Backend | Latence |
|-----------|---------|---------|
| STT Whisper | CPU (whisper.cpp) | 1500ms |
| STT Whisper | NPU (ExecuTorch) | ~50ms* |
| LLM Qwen3-0.6B | NPU (ExecuTorch) | 93 tok/s, TTFT 31ms |
| LLM Qwen3-1.7B | NPU (ExecuTorch) | 46 tok/s, TTFT 27ms |
| TTS Android | Google | 200ms |
| Pipeline total (CPU STT) | STT→LLM→TTS | ~3-7s |
| Pipeline total (NPU STT)* | STT→LLM→TTS | ~1-3s |
*STT NPU en cours d'intégration
## Fichiers
```
kazeia-android/app/src/main/java/com/kazeia/
├── core/
│ ├── LlmEngine.kt # Interface LLM
│ ├── SttEngine.kt # Interface STT
│ ├── TtsEngine.kt # Interface TTS
│ ├── VadEngine.kt # Interface VAD
│ ├── ConversationState.kt # États pipeline
│ └── Pipeline.kt # Interfaces MessageProcessor, PipelineOrchestrator
├── llm/
│ ├── ExecuTorchLlmEngine.kt # LLM sur NPU via ExecuTorch
│ └── GenieLlmEngine.kt # LLM via Genie SDK (abandonné)
├── stt/
│ ├── WhisperSttEngine.kt # STT CPU via whisper.cpp
│ ├── WhisperNpuSttEngine.kt # STT NPU via ExecuTorch
│ └── AndroidSttEngine.kt # STT cloud via Google
├── tts/
│ ├── AndroidTtsEngine.kt # TTS Google natif
│ └── ChatterboxTtsEngine.kt # TTS avec clonage voix
├── conversation/
│ ├── LlmProcessor.kt # Processor LLM
│ ├── EchoProcessor.kt # Processor écho
│ ├── VoiceCommandProcessor.kt # Commandes vocales (config JSON)
│ ├── VoiceCommandProcessor2.kt # Adapter MessageProcessor
│ ├── PromptBuilder.kt # Construction prompts
│ └── StoppingCriteria.kt # Critères d'arrêt
├── service/
│ ├── KazeiaService.kt # Service Android foreground
│ └── KazeiaPipeline.kt # Orchestrateur pipeline modulaire
└── ui/
├── ChatActivity.kt # Interface utilisateur
├── ChatAdapter.kt # Adapter RecyclerView
├── MiniGraphView.kt # Graphe temps réel
└── ResourceMonitor.kt # Monitoring CPU/GPU/RAM
```
---
*Projet Kazeia — Damien Micottis & Richard Loyer*

691
AVATAR_3D_RAPPORT.md Normal file
View File

@ -0,0 +1,691 @@
# Rapport technique — Avatar 3D pour Kazeia
## Face cloning, voice cloning, lip sync temps réel
### 2026-04-02
---
## 1. Vision
Kazeia est un compagnon de support émotionnel. L'avatar 3D donne un visage à ce compagnon :
- **Mode enfant** : personnage stylisé (ours en peluche, mascotte) avec expressions et lip sync
- **Mode ado/adulte** : visage humain photoréaliste reconstruit depuis une photo/vidéo de la personne ayant donné son consentement (thérapeute, proche, éducateur...)
L'avatar parle avec la voix clonée de cette même personne (voice cloning Qwen3-TTS), créant une expérience cohérente visage + voix.
---
## 2. Hardware cible
| Composant | Spec |
|-----------|------|
| Tablette | OnePlus Pad 3 (OPD2415) |
| SoC | Snapdragon 8 Elite (SM8750P) |
| Écran | 12.1", 2400×3392, 144Hz, HDR10/DolbyVision, 420 dpi |
| GPU | Adreno 830 — **100% libre** (ML sur NPU/Hexagon) |
| RAM | 12 GB (~8 GB disponibles après ML + OS) |
| NPU | Hexagon (HMX FP16 via ggml-hexagon + HTP via QNN SDK) |
Un visage affiché en gros plan sur cet écran fait **~2000×2000 pixels effectifs**. La barre de qualité est haute.
---
## 3. Répartition des ressources
```
Hexagon NPU (HMX FP16):
├── TTS Talker (27ms/step)
└── TTS Code Predictor (86ms/step)
Hexagon NPU (HTP via QNN):
├── TTS Decoder (3.5s, séquentiel après runners)
├── Whisper STT (~600ms)
└── LLM Qwen3-0.6B (93 tok/s)
CPU:
├── Sampling, IPC, embeddings (trivial)
├── Silero VAD
├── MediaPipe Face Mesh (capture, ~5ms)
└── OVRLipSync (~2ms)
GPU Adreno 830 (100% dédié avatar):
├── Rendu 3D avatar (50-80K vertices, <15% GPU)
├── Blendshape animation (52 shapes, 60fps)
├── PBR shading + subsurface scattering approximé
└── Marge : >80% GPU libre
```
---
## 4. Protocole de capture — Séquence unique (~60 secondes)
### Principe
Une seule séquence de capture sert 3 objectifs :
1. **Preuve légale** de consentement (vidéo horodatée)
2. **Clonage vocal** (extraction x-vector depuis l'audio)
3. **Création avatar** (géométrie 3D, texture, calibration expressions)
### Phase 1 — Rotation guidée (20s)
L'utilisateur tourne lentement la tête, guidé par un overlay face tracking (MediaPipe Face Mesh, 468 landmarks).
```
Captures automatiques aux angles détectés :
④ ② ① ③ ⑤
60° gauche 30° G FACE 30° D 60° droite
```
- 5-7 photos **pleine résolution** (8-16 MP caméra frontale)
- Expression **neutre** demandée
- Pas de vidéo — photos nettes sans motion blur
- Face tracking valide l'angle et la netteté avant déclenchement
**Données extraites** : géométrie 3D (multi-view FLAME fitting), texture UV côtés
### Phase 2 — Vidéo de consentement (15s)
Texte affiché en mode prompteur. L'utilisateur lit face caméra :
> *"J'autorise l'application Kazeia à utiliser mon visage et ma voix, dans le cadre exclusif de cette application, pour créer mon avatar personnel."*
Formulation choisie pour :
- Durée ~6s (minimum pour x-vector fiable : 5s)
- Phonèmes variés (voyelles : a/o/i/u/e, consonnes : k/z/p/l/v/d/m)
- Clarté juridique (cadre exclusif, avatar personnel)
**Données extraites** :
- **Audio WAV 16kHz** → extraction x-vector (1024 floats) pour voice cloning Qwen3-TTS
- **Preuve légale** → vidéo horodatée, chiffrée, stockée sur l'appareil
- **Frames vidéo** (~450 frames à 30fps) → sélection automatique des meilleurs frames :
- Frame neutre → texture frontale haute résolution supplémentaire
- Frames bouche ouverte → calibration visèmes personnalisés
- Frames sourire naturel → calibration blendshape sourire
### Phase 3 — Expressions guidées (10s)
5 expressions demandées rapidement, toujours frontal :
| # | Expression | Ce qu'elle calibre |
|---|-----------|-------------------|
| 1 | Neutre | Référence de repos |
| 2 | Sourire (bouche fermée) | Commissures, joues, pommettes |
| 3 | Bouche ouverte ("ah") | Mâchoire, lèvres intérieures |
| 4 | Sourcils levés | Front, paupières supérieures |
| 5 | Yeux fermés | Paupières, cils |
**Données extraites** : blendshapes personnalisés (pas les génériques FLAME)
### Phase optionnelle — Gros plan iris (5s)
- Capture rapprochée d'un oeil
- Couleur et motif de l'iris pour des yeux réalistes
- Les yeux sont le premier point de focalisation du regard
### Conditions de capture
- **Éclairage** : diffus, face à une fenêtre, pas de soleil direct ni flash
- **Fond** : neutre (mur uni) pour faciliter la segmentation
- **Position** : stable, tablette posée ou tenue à bout de bras
- **Même lumière** pour toutes les phases (cohérence texture)
### UX guidée
```
┌─────────────────────────────────────┐
│ │
│ ┌───────────────────────────┐ │
│ │ Caméra frontale │ │
│ │ │ │
│ │ Face tracking overlay │ │
│ │ + cible de pose │ │
│ │ ◎ ───► │ │
│ │ │ │
│ │ "Tournez la tête │ │
│ │ vers la droite" │ │
│ └───────────────────────────┘ │
│ │
│ ● ● ● ○ ○ ○ ○ ○ ○ ○ 3/10 │
│ │
│ [Recommencer cette étape] │
└─────────────────────────────────────┘
```
MediaPipe Face Mesh détecte en temps réel :
- Angle de la tête (yaw/pitch/roll) → validation de pose
- Expression faciale → validation avant capture
- Netteté (variance du Laplacien) → rejet des frames floues
---
## 5. Pipeline de reconstruction 3D
### 5.1 Géométrie — FLAME multi-view fitting
```
5-7 photos angles + frames vidéo
DECA / EMOCA (ResNet-50 backbone, ~25M params)
Paramètres FLAME :
├── 300 shape params (identité : nez, mâchoire, pommettes...)
├── 50 expression params (blendshapes)
└── Pose (rotation tête)
Mesh FLAME brut : ~5K vertices
Subdivision Catmull-Clark (2 passes)
Mesh final : ~80K vertices, topology régulière
```
- DECA inference : ~200ms sur GPU Adreno ou NPU (one-shot, une seule fois)
- Multi-view fitting améliore significativement vs single-image : profondeur du nez, mâchoire, pommettes
- La subdivision est offline (une fois) — ne rajoute pas de coût au rendu
### 5.2 Texture — Projection multi-vue + super-résolution
```
5-7 photos angles
Projection UV par vue
(chaque pixel photo → coordonnée UV sur le mesh)
Blending multi-vue (pondéré par angle de vue)
Texture UV 2048×2048 (données directes ~90%)
Inpainting zones manquantes (<10% : dessous menton, intérieur oreilles)
Super-résolution (Real-ESRGAN ou équivalent)
Texture finale 4096×4096
```
Cartes additionnelles pour le photoréalisme :
- **Normal map** : détail des pores (halluciné par ML depuis la texture diffuse, e.g. DECA detail map)
- **Roughness/specular map** : zones brillantes (nez, front) vs mates (joues)
- **Subsurface scattering** : paramètres peau génériques (difficile à capturer depuis tablette)
### 5.3 Blendshapes personnalisés
```
5 photos expressions + frames vidéo parole
DECA/EMOCA par frame → 50 expression params
Optimisation : ajuster les bases FLAME pour coller
au visage spécifique (sourire de cette personne,
pas le sourire "moyen" FLAME)
52 blendshapes ARKit-compatibles personnalisés
```
Mapping FLAME → ARKit quasi 1:1 :
- `jawOpen`, `mouthClose`, `mouthFunnel`, `mouthPucker`, `mouthLeft/Right`
- `eyeBlinkLeft/Right`, `browDownLeft/Right`, `browInnerUp`
- `cheekPuff`, `cheekSquintLeft/Right`
- etc.
### 5.4 Yeux
- Iris : texture extraite du gros plan (ou frame vidéo haute-res)
- Sclera : blanc procédural avec veinules subtiles
- Cornée : reflet spéculaire procédural (point lumineux)
- Humidité : couche transparente réflective
- **Animation** : saccades oculaires aléatoires, suivi regard (optionnel)
### 5.5 Cheveux
Approche pragmatique pour la v1 :
- **Carte alpha** (billboard cards) suivant la forme détectée
- Pas de rendu strand-level (trop coûteux, pas nécessaire pour un cadrage visage)
- Couleur extraite de la photo
- Alternative : cadrage serré (front → menton) qui évite le problème
---
## 6. Rendu temps réel
### 6.1 Unity as a Library (UaaL)
Unity embarqué dans l'app Android native via UaaL :
- Export Android AAR depuis Unity
- `UnityPlayerActivity` lancée depuis l'app Kotlin
- Communication bidirectionnelle : `UnitySendMessage` (Java→C#) et `AndroidJavaObject` (C#→Java)
Contraintes :
- Unity exige sa propre Activity (pas un Fragment)
- Un seul player Unity actif à la fois
- Lifecycle : pause/resume coordonné avec les autres composants
### 6.2 Specs rendu
| Paramètre | Valeur |
|-----------|--------|
| Mesh | 50-80K vertices, topology quad subdivisée |
| Texture diffuse | 4096×4096 |
| Normal map | 2048×2048 (détail pores) |
| Roughness map | 1024×1024 |
| Blendshapes | 52 (ARKit-compatible) |
| Shading | PBR + subsurface scattering approx. (skin shader) |
| Target framerate | 60fps (stabilité thermique pour sessions longues de 15-30 min) |
| Résolution rendu | Natif 2400×3392 ou 80% avec upscale |
| Antialiasing | MSAA 4x ou TAA |
| Éclairage | 1 directionnelle + 1 ambient + IBL (image-based lighting) |
Budget GPU estimé : <15% de l'Adreno 830 pour un seul personnage.
### 6.3 Style visuel
**Approche photoréaliste en premier**, fallback semi-stylisé si qualité insuffisante :
Pour le photoréalisme :
- Skin shader avec subsurface scattering (pre-integrated SSS ou screen-space SSS)
- Normal map pore-level
- Specular lobe dual (peau grasse vs sèche)
- Eye shader avec refraction cornée
- Anti-aliasing agressif (les bords du visage à 3392px montrent tout)
Si le résultat tombe dans l'uncanny valley :
- Passer en semi-stylisé (peau lissée, yeux légèrement agrandis, shader toon subtil)
- Réduit les exigences de texture et de normal map
- Plus chaleureux pour du support émotionnel
---
## 7. Lip sync
### 7.1 Pipeline
```
TTS Qwen3-TTS → Audio PCM 24kHz
OVRLipSync (Meta, Android NDK)
ou uLipSync (Unity, plus léger)
15 visèmes Oculus → mapping vers 52 blendshapes ARKit
Interpolation cubique Hermite (pas linéaire)
Coarticulation (lookahead 2-3 frames)
Blendshape weights à 60fps → SkinnedMeshRenderer Unity
```
### 7.2 Qualité lip sync sur grand écran
Les lèvres font ~300px de large à plein écran. Exigences :
- **52 blendshapes ARKit** (pas seulement 15 visèmes) — lèvres supérieure/inférieure/gauche/droite indépendantes
- **60fps** de mise à jour des poids (pas 30 — les transitions sont visibles sur 144Hz)
- **Interpolation cubique Hermite** entre keyframes — le linéaire donne un effet robotique
- **Coarticulation** : le shape de la bouche pour "ba" dépend de la voyelle suivante. 2-3 frames de lookahead dans le buffer audio
### 7.3 Mapping visèmes → blendshapes
Les 15 visèmes OVR se décomposent en mouvements de blendshapes multiples :
| Visème OVR | Blendshapes ARKit activés |
|-----------|--------------------------|
| PP (p/b/m) | mouthClose + mouthPucker |
| FF (f/v) | mouthFunnel + jawOpen(0.1) |
| TH (th) | tongueOut + jawOpen(0.2) |
| AA (a) | jawOpen(0.6) + mouthWideLeft/Right |
| OO (ou) | mouthPucker + mouthFunnel + jawOpen(0.3) |
| EE (i/e) | mouthSmileLeft/Right + jawOpen(0.2) |
| ... | ... |
Chaque visème active 2-5 blendshapes avec des poids différents → résultat plus riche que 15 shapes binaires.
---
## 8. Expressions émotionnelles
### 8.1 Émotions depuis le LLM
Le LLM Qwen3-0.6B tagger ses réponses avec des émotions **inline**, y compris au milieu des phrases :
```
Prompt système :
"Insère des tags d'émotion [joie], [tristesse], [empathie], [encouragement],
[neutre], [surprise] dans ta réponse quand l'émotion change."
Réponse LLM :
"[empathie] Je comprends que cette situation soit difficile.
[encouragement] Mais tu as déjà fait un grand pas en en parlant.
[joie] C'est vraiment courageux de ta part."
```
L'app parse les tags au fil du texte. Chaque tag déclenche une transition d'expression sur l'avatar **synchronisée avec le TTS** — l'émotion change au moment où la phrase correspondante est prononcée, pas avant.
### 8.2 Mapping émotion → expressions faciales
| Émotion LLM | Blendshapes dominants | Intensité |
|-------------|----------------------|-----------|
| joie | mouthSmile + cheekSquint + eyeSquint | 0.6-0.8 |
| tristesse | browInnerUp + mouthFrown + eyeWide(0.1) | 0.4-0.6 |
| empathie | browInnerUp(0.3) + mouthSmile(0.2) + headTilt | 0.3-0.5 |
| encouragement | browUp + mouthSmile(0.5) + nod | 0.5-0.7 |
| surprise | eyeWide + browUp + jawOpen(0.3) | 0.5-0.7 |
| neutre | repos + micro-expressions | 0.0-0.1 |
Les expressions se **blendent avec le lip sync** — l'avatar peut sourire tout en parlant.
### 8.3 Transitions
- Transition entre émotions : **ease-in-out sur 500ms** (pas de snap brutal)
- L'émotion s'applique sur la durée de la phrase TTS
- Les micro-expressions (léger sourire, haussement de sourcils) ajoutent du naturel
---
## 9. Idle animations — L'avatar vivant
Un avatar figé quand il ne parle pas = immédiatement "mort". Animations subtiles obligatoires :
| Animation | Fréquence | Amplitude |
|-----------|-----------|-----------|
| Clignement des yeux | Aléatoire, ~15-20/min | Naturel (rapide : 150ms) |
| Micro-saccades oculaires | Continu, 2-3/s | ±2° aléatoire |
| Respiration | Continue, ~16/min | Léger mouvement épaules/poitrine |
| Micro-expressions | Aléatoire, toutes les 3-8s | Très subtil (0.02-0.05) |
| Mouvement tête | Lent, continu | ±2° drift aléatoire |
Ces animations sont procédurales (pas des clips) — elles se blendent naturellement avec le lip sync et les émotions.
---
## 10. Mode enfant
### Pipeline simplifié
```
Asset pré-fait (ours en peluche .glb)
├── Mesh : 10-20K vertices
├── 15-20 blendshapes (visèmes + sourire + triste + surprise)
├── Texture : stylisée, pré-faite
└── Rig : squelette simple (tête + corps)
Même pipeline lip sync + émotions
Pas de capture nécessaire
```
- Pas de reconstruction faciale
- Pas de voice cloning (voix synthétique par défaut ou voix pré-enregistrée)
- L'ours en peluche fait les mêmes expressions et lip sync que l'avatar humain
- **Premier livrable** pour valider le pipeline complet avant le mode adulte
---
## 11. Multi-utilisateurs et profils
### Modèle de profils
Un **profil** = un avatar (visage + voix). Un **utilisateur** = une personne qui utilise Kazeia.
```
Profil "Dr. Martin"
├── Avatar 3D (mesh, texture, blendshapes)
├── X-vector voix
├── Vidéo consentement
└── Permissions :
├── Utilisateur: Léa (autorisé)
├── Utilisateur: Hugo (autorisé)
└── Mode: multi-utilisateur (autorisé par Dr. Martin)
Profil "Maman de Léa"
├── Avatar 3D + voix
└── Permissions :
└── Utilisateur: Léa uniquement (mono-utilisateur)
```
### Règles
- **Création** : la personne filmée crée le profil et donne son consentement
- **Mono-utilisateur** (par défaut) : le profil n'est utilisable que par un seul utilisateur désigné
- **Multi-utilisateur** : la personne doit explicitement autoriser le partage lors du consentement
- Phrase modifiée : *"...pour un usage partagé avec les utilisateurs que j'autorise"*
- **Révocation** : la personne peut révoquer son profil à tout moment (suppression avatar + voix, vidéo consentement conservée)
- **Pas de transfert** : un profil ne peut pas être copié vers un autre appareil (lié au device)
### Stockage profils
```
/data/data/com.kazeia/profiles/
├── profile_001/
│ ├── consent_video.enc (chiffré, non supprimable)
│ ├── avatar_mesh.enc (chiffré)
│ ├── avatar_texture.enc (chiffré)
│ ├── avatar_blendshapes.enc (chiffré)
│ ├── speaker_xvector.enc (chiffré)
│ └── metadata.json (permissions, utilisateurs autorisés)
└── profile_002/
└── ...
```
---
## 12. Mode dégradé
Si la personne ne souhaite pas un avatar photoréaliste de son visage :
| Option | Description | Voix |
|--------|------------|------|
| **Semi-stylisé** | Son visage mais en style 3D animation (Pixar-like) | Sa voix clonée |
| **Avatar générique** | Visage prédéfini parmi un catalogue | Sa voix clonée |
| **Personnage** | Ours en peluche ou autre mascotte | Sa voix clonée |
| **Voix seule** | Pas d'avatar, écran avec animation abstraite | Sa voix clonée |
Le choix est fait lors de la création du profil. Le voice cloning reste disponible dans tous les modes (seul le visuel change).
Le style semi-stylisé utilise le même pipeline de capture mais applique un **style transfer** au rendu :
- Peau lissée (pas de pores)
- Yeux légèrement agrandis
- Proportions adoucies
- Shader toon subtil au lieu de PBR réaliste
---
## 13. Mise à jour du profil
L'utilisateur ou la personne du profil peut mettre à jour les photos :
- **Ajout de photos** : nouvelles vues → amélioration texture + géométrie
- **Changement d'apparence** : lunettes, coupe de cheveux → re-capture partielle
- **Vieillissement** : re-capture complète tous les X mois si souhaité
- **Process** : même protocole de capture (phase 1 + 3), pas besoin de re-filmer le consentement
- **Versioning** : l'ancien avatar est conservé jusqu'à validation du nouveau
---
## 14. Simultanéité TTS + Avatar
L'avatar et le TTS travaillent ensemble en temps réel :
```
Timeline d'une réponse Kazeia :
t=0s LLM génère texte + tag émotion
t=0.1s Avatar reçoit émotion → transition expression (500ms ease-in)
t=0.1s TTS démarre génération (Hexagon NPU)
t=6s Génération terminée → hexStopRunner()
t=6.5s QNN decode audio (3.5s)
t=10s Audio prêt → AudioTrack.play()
→ OVRLipSync analyse en temps réel
→ Blendshape weights envoyés à Unity à 60fps
→ Avatar parle avec lip sync + émotion
t=14s Audio terminé → avatar retour idle (ease-out 500ms)
```
**Pas de streaming pour l'instant** (conflit DSP hexagon/QNN). L'avatar affiche l'émotion et les idle animations pendant la génération TTS, puis commence le lip sync quand l'audio est prêt.
Le lip sync est synchrone avec l'audio — Unity lit le même buffer PCM que AudioTrack.
---
## 15. Consentement et données personnelles
### Stockage sur l'appareil
| Donnée | Taille | Chiffré | Supprimable | Durée |
|--------|--------|---------|-------------|-------|
| Vidéo consentement | ~30 MB | AES-256 | **Non** (preuve légale) | Permanent |
| Photos capture | ~50 MB | Non (temp) | **Supprimées** après traitement | ~2 min |
| Audio brut | ~1 MB | Non (temp) | **Supprimé** après extraction x-vector | ~1 min |
| X-vector voix | 4 KB | AES-256 | Oui (supprime avatar) | Tant que avatar existe |
| Mesh avatar | ~5 MB | AES-256 | Oui | Tant que avatar existe |
| Texture avatar | ~15 MB | AES-256 | Oui | Tant que avatar existe |
| Blendshapes | ~2 MB | AES-256 | Oui | Tant que avatar existe |
### Principes RGPD
- **Rien ne quitte l'appareil** — tout le traitement est on-device
- **Consentement explicite** — vidéo avec déclaration orale comme preuve
- **Droit à l'effacement** — l'utilisateur peut supprimer son avatar (sauf vidéo consentement)
- **Minimisation** — photos et audio brut supprimés dès que traités
- **Pas de biométrie stockée** — le x-vector seul ne permet pas de reconnaître une voix (vecteur de 1024 dimensions, non réversible)
---
## 16. Architecture d'intégration
```
┌─────────────────────────────────────────────────────┐
│ App Kazeia │
│ │
│ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ ChatActivity │ │ UnityPlayerActivity │ │
│ │ (Conversation) │◄──►│ (Avatar 3D) │ │
│ │ │ │ │ │
│ │ - Chat UI │ │ - Rendu avatar │ │
│ │ - Contrôles │ │ - Blendshape anim. │ │
│ │ - État session │ │ - Lip sync │ │
│ └───────┬──────────┘ │ - Idle anims │ │
│ │ │ - Émotions │ │
│ │ └───────┬───────────────┘ │
│ │ │ │
│ ┌───────▼───────────────────────▼───────────────┐ │
│ │ KazeiaService │ │
│ │ │ │
│ │ Whisper STT (NPU) → LLM Qwen3 (NPU) │ │
│ │ → TTS Qwen3 (Hexagon + QNN) │ │
│ │ │ │
│ │ Events émis : │ │
│ │ ├── onTtsAudioChunk(pcm) → lip sync │ │
│ │ ├── onEmotion(tag) → expression avatar │ │
│ │ ├── onSpeechStart() → avatar attentif │ │
│ │ └── onSpeechEnd() → avatar retour idle │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
### Communication KazeiaService ↔ Unity
- **Java → C#** : `UnityPlayer.UnitySendMessage("AvatarController", "OnEmotion", "joie")`
- **Audio → lip sync** : SharedMemory ou AudioTrack partagé — Unity lit le même buffer audio
- **C# → Java** : callback via `AndroidJavaObject` pour les événements Unity (avatar ready, etc.)
---
## 17. Phases d'implémentation
### Phase 1 — Proof of concept (2-3 semaines)
- [ ] Projet Unity avec UaaL export Android
- [ ] Asset ours en peluche avec 15 blendshapes
- [ ] Lip sync basique (OVRLipSync ou uLipSync)
- [ ] Intégration dans kazeia-android (UnityPlayerActivity)
- [ ] Bridge : audio TTS → lip sync Unity
- [ ] Idle animations (clignements, respiration)
**Livrable** : ours en peluche qui parle avec lip sync depuis le TTS Kazeia
### Phase 2 — Face cloning (3-4 semaines)
- [ ] UI de capture guidée (MediaPipe Face Mesh)
- [ ] Vidéo consentement + extraction audio
- [ ] Voice cloning : extraction x-vector, remplacement de l'embedding
- [ ] DECA/EMOCA inference on-device
- [ ] Multi-view FLAME fitting
- [ ] Subdivision mesh + projection texture UV
- [ ] Super-résolution texture
- [ ] Export mesh + blendshapes vers Unity
**Livrable** : avatar personnalisé depuis selfies, voix clonée
### Phase 3 — Photoréalisme (2-3 semaines)
- [ ] Skin shader PBR + subsurface scattering
- [ ] Normal map pore-level
- [ ] Eye shader (iris, cornée, reflets)
- [ ] Cheveux (cards alpha ou cadrage serré)
- [ ] Émotions LLM → expressions faciales
- [ ] Blending émotions + lip sync
- [ ] Polish : micro-expressions, transitions fluides
**Livrable** : avatar photoréaliste avec expressions émotionnelles
### Phase 4 — Polish et optimisation (1-2 semaines)
- [ ] Profiling GPU (vérifier budget <15%)
- [ ] Optimisation mémoire (LOD, texture streaming)
- [ ] Gestion lifecycle Unity ↔ Android robuste
- [ ] Chiffrement données avatar
- [ ] UI de gestion avatar (créer, supprimer, changer)
- [ ] Test uncanny valley → décision photoréaliste vs semi-stylisé
---
## 18. Risques et mitigations
| Risque | Impact | Mitigation |
|--------|--------|------------|
| Uncanny valley (visage réaliste mal animé) | Rejet utilisateur | Fallback semi-stylisé préparé en parallèle |
| Texture basse qualité depuis caméra frontale | Avatar flou en gros plan | Super-résolution + normal map halluciné |
| DECA insuffisant pour photoréalisme | Géométrie approximative | Multi-view fitting + calibration expressions |
| UaaL lifecycle complexe | Crashs, fuites mémoire | Isolation stricte, tests intensifs |
| Lip sync saccadé sur grand écran | Effet robotique | 52 blendshapes + cubique Hermite + 60fps |
| Cheveux difficiles à rendre | Apparence artificielle | Cadrage serré (front→menton) pour v1 |
| Taille APK Unity (+40-80 MB) | Download lourd | Asset bundles, chargement à la demande |
| Performance GPU inattendue | Framerate bas | Budget large (15% estimé), marge 85% |
---
## 19. Résumé
| Aspect | Choix |
|--------|-------|
| Moteur 3D | Unity (UaaL) embarqué dans app Android native |
| Capture | 10 photos guidées + vidéo consentement (~60s) |
| Reconstruction | DECA/FLAME multi-view → 80K vertices subdivisé |
| Texture | Projection multi-vue 4096×4096 + super-résolution |
| Blendshapes | 52 ARKit-compatibles, personnalisés par expressions |
| Lip sync | OVRLipSync → 52 blendshapes, interpolation cubique, 60fps |
| Émotions | Tag LLM → expressions faciales blendées avec lip sync |
| Voice cloning | X-vector extrait de la vidéo de consentement |
| Style | Photoréaliste (fallback semi-stylisé si uncanny valley) |
| Mode enfant | Asset pré-fait (ours en peluche), même pipeline lip sync |
| Données | 100% on-device, chiffré, RGPD-compatible |
| GPU | Adreno 830, <15% utilisé, 100% dédié avatar |

416
BENCHMARK_RAPPORT.md Normal file
View File

@ -0,0 +1,416 @@
# Rapport de Benchmark - Kazeia sur OnePlus Pad 3
**Date** : 24-25 mars 2026
**Tablette** : OnePlus Pad 3
**Réalisé par** : Claude Code (Opus 4.6) à la demande de l'utilisateur
---
## 1. Configuration matérielle de la tablette
| Spécification | Valeur |
|---|---|
| **SoC** | Qualcomm Snapdragon 8 Elite (SM8750) |
| **CPU** | Qualcomm Oryon (8 coeurs, jusqu'à 4.32 GHz) |
| **GPU** | Adreno 830 (Vulkan 1.3) |
| **NPU** | Hexagon HTP v79 (~75 TOPS INT8 / ~145 TOPS INT4) |
| **RAM** | 15.8 Go (LPDDR5X, ~77 Go/s bande passante) |
| **Stockage** | 512 Go (455 Go libres) |
| **OS** | Android 16 (SDK 36) |
| **Architecture** | aarch64 |
| **Features ARM** | fp, asimd, i8mm, bf16, sha512, sve-like |
---
## 2. Environnement de test
### PC de développement (cross-compilation)
- **OS** : Fedora 43 (x86_64)
- **RAM** : 54 Go
- **CPU** : 16 coeurs
- **Compilateurs** : GCC 15.2.1, CMake 3.31.11, Ninja 1.13.1
- **NDK** : Android NDK r27d
- **Frameworks** : llama.cpp (build 8508), ExecuTorch (HEAD), Genie SDK (QNN 2.37/2.42)
### Communication
- **ADB** : Connecté via USB (device ID: 9e4abcaf)
- **Transfert** : ~35 Mo/s pour les modèles, ~300 Mo/s pour les binaires
---
## 3. Résultats des benchmarks
### 3.1 Test CPU (NEON ARM64) - llama.cpp
**Framework** : llama.cpp compilé via NDK r27d, backend CPU NEON
**Compilation** : `GGML_CPU_AARCH64=ON`, `GGML_OPENMP=OFF`
#### Gemma 3 4B - Scaling par nombre de threads
| Threads | Prefill 512 tok (tok/s) | Decode 128 tok (tok/s) | Scaling |
|---:|---:|---:|---|
| 1 | 2.18 | 1.71 | baseline |
| 2 | 7.48 | 3.35 | ~2x |
| 4 | 14.92 | 6.55 | ~3.8x |
| 6 | 19.16 | 11.16 | ~6.5x |
| **8** | **23.22** | **16.00** | **~9.4x** |
#### Tous les modèles testés (8 threads, tablette à 25-30°C)
| Modèle | Taille | Quant | Prefill 512 (tok/s) | Decode 128 (tok/s) |
|---|---:|---|---:|---:|
| **Qwen3-0.6B** | 604 Mo | Q8_0 | **163.25** | **68.23** |
| Qwen3-4B | 2.32 Go | Q4_K_M | 19.92 | 15.51 |
| Gemma 3 4B | 2.31 Go | Q4_K_M | 23.22 | 16.00 |
| Gemma 3 4B | 2.20 Go | Q4_0 | 23.54 | 16.31 |
**Observations CPU** :
- Le scaling est quasi-linéaire jusqu'à 8 threads (excellent pour les coeurs Oryon)
- Q4_K_M et Q4_0 ont des performances quasi identiques (~1% de différence)
- Le petit modèle Qwen3-0.6B atteint **68 tok/s** grâce à sa taille réduite (tient dans le cache)
- **Thermal throttling sévère** : après usage intensif, la tablette chauffe à 55°C et les performances chutent à ~5-7 tok/s (÷3)
---
### 3.2 Test GPU Vulkan (Adreno 830) - ÉCHEC
**Modèle** : Gemma 3 4B (Q4_K_M et Q4_0)
**Framework** : llama.cpp compilé avec `GGML_VULKAN=ON`
**Headers Vulkan** : Khronos Vulkan-Headers v1.3.275 (aligné avec le NDK)
| Test | Modèle | Config | Résultat |
|---|---|---|---|
| Vulkan ngl=99 | Q4_K_M | Full GPU offload | **CRASH** : `Compute pipeline creation failed for mul_mat_vec_q4_k_f32_f32` |
| Vulkan ngl=99 | Q4_0 | Full GPU offload | **CRASH** : `vk::Queue::submit: ErrorDeviceLost` |
| Vulkan ngl=1 | Q4_0 | 1 layer GPU | **0.65 tok/s** (25x plus lent que CPU) |
**Verdict** : Le GPU Vulkan Adreno 830 est **inutilisable** pour l'inférence LLM via llama.cpp. Les compute shaders ne sont pas compatibles avec le driver Vulkan Qualcomm.
---
### 3.3 Test NPU (Hexagon HTP v79) - Genie SDK
**Framework** : Qualcomm Genie SDK
**Backend** : QnnHtp avec context binaries pré-compilés (Qualcomm AI Hub)
#### Qwen3-4B (modèle pré-compilé Qualcomm AI Hub)
| Métrique | Valeur |
|---|---|
| SDK | QNN 2.42.0 |
| Format | 4 context binaries w4a16 (total ~3 Go) |
| Tokens générés | 4096 (contexte max) |
| Temps total | ~207 secondes |
| **Débit decode** | **~19.8 tok/s** |
| RAM allouée | 344 Mo (8 shared buffers) |
#### Qwen3-0.6B (conversion manuelle via transformer-composer)
| Tentative | Backend | Résultat |
|---|---|---|
| `QnnGenAiTransformer` | CPU (via Genie) | **CRASH** : dimensions de tenseurs incompatibles (GQA non supporté) |
Le backend `QnnGenAiTransformer` du SDK 2.37 ne gère pas correctement l'architecture GQA (Grouped Query Attention) de Qwen3. Seuls les modèles **pré-compilés Qualcomm AI Hub** avec le backend `QnnHtp` fonctionnent de manière fiable.
---
### 3.4 Test NPU (Hexagon HTP v79) - ExecuTorch + QNN
**Framework** : ExecuTorch (Meta) + QNN delegate (Qualcomm)
**SDK** : QNN 2.42.0
**Modèles** : Qwen3-0.6B, Qwen3-1.7B et Mistral-Nemo 12B exportés au format `.pte` avec quantification INT4
#### Parcours de mise en place
| Étape | Résultat | Notes |
|---|---|---|
| Build Android ARM64 (`llama_main`) | **OK** | 5.3 Mo |
| Build x86_64 (`PyQnnManagerAdaptor`) | **OK** | Patch GCC 15 nécessaire |
| Quantification INT4 (`decode_qdq.pt2`) | **OK** | 4.2 Go (0.6B) / 12 Go (1.7B) / 62 Go (12B) |
| Compilation graphe QNN HTP v79 | **OK** | ~20 min (0.6B) / ~25 min (1.7B) / ~1h40 (12B, 8 shards) |
| Sérialisation `.pte` | **OK** | 660 Mo (0.6B) / 1.7 Go (1.7B) / 7.4 Go (12B) |
| Déploiement + inférence tablette | **OK** | Via script `llama.py` |
**Patches GCC 15 appliqués** :
1. `third-party/flatcc/include/flatcc/portable/grisu3_print.h` : `char hexdigits[16]``[17]`
2. `extension/llm/tokenizers/third-party/sentencepiece/src/sentencepiece_processor.h` : ajout `#include <cstdint>`
#### Résultats ExecuTorch Qwen3-0.6B
| Métrique | Valeur |
|---|---|
| **Débit decode** | **69.3 tok/s** |
| Taille .pte | 660 Mo |
| RAM modèle | 694 Mo |
| Réponse | Français correct avec thinking mode |
#### Résultats ExecuTorch Qwen3-1.7B
| Métrique | Valeur |
|---|---|
| **Débit decode** | **25.7 tok/s** |
| Taille .pte | 1.7 Go |
| Tokens générés | 442 tokens en 17.2 secondes |
| Temps d'inférence total | 17.175 s |
| Réponse | Excellent français, réponse empathique structurée |
**Exemple de réponse Qwen3-1.7B sur le NPU** :
> Bonjour, je suis désolé de vous voir triste. C'est vraiment douloureux de voir des sentiments aussi difficiles. Mais je suis ici pour vous soutenir.
> - Passez un peu de temps à l'air : l'oxygène et le soleil peuvent aider à détendre l'esprit.
> - Parlez à quelqu'un : partager vos émotions avec une amie, un proche ou même un thérapeute peut être très bénéfique.
> - Faites quelque chose de votre passion : une activité physique, une lecture peut vous aider à vous distraire.
> - Respirez : une respiration profonde peut aider à calmer l'esprit.
Ce modèle est le **meilleur candidat pour Kazeia** : réponses empathiques de qualité en français, vitesse excellente (25.7 tok/s), et taille raisonnable (1.7 Go).
#### Résultats ExecuTorch Mistral-Nemo 12B (modèle utilisé par kazeia.py)
Export réussi grâce à :
- Ajout d'un profil custom `mistral_nemo_12b` dans ExecuTorch (config + `convert_weights` + `QuantRecipe`)
- `num_sharding = 8` (40 couches ÷ 8 = 5 couches par shard)
- 192 Go de swap sur btrfs (`btrfs filesystem mkswapfile`)
| Métrique | Valeur |
|---|---|
| **Débit decode** | **5.1 tok/s** |
| **Débit prefill** | **156.9 tok/s** |
| Taille .pte | 7.4 Go |
| Tokens générés | 244 en 47.7 secondes |
| Réponse | Excellent français, empathique, conseil professionnel |
**Exemple de réponse Mistral-Nemo 12B sur le NPU** :
> Je suis désolé que vous vous sentiez triste. Il y a plusieurs choses que vous pouvez faire pour vous sentir mieux. Vous pouvez essayer de faire de l'exercice, de méditer, de parler à un ami ou à un membre de votre famille, de vous offrir une petite gâterie, ou de vous reposer. Vous pouvez également essayer de vous concentrer sur les choses positives de votre vie. Si votre tristesse est persistante, il peut être utile de parler à un professionnel de la santé mentale pour obtenir de l'aide supplémentaire.
**Observation clé** : le prefill est **21x plus rapide** que le CPU (156.9 vs 7.25 tok/s) grâce aux TOPS du NPU, mais le decode est **16% plus lent** (5.1 vs 6.05 tok/s) car le modèle de 7.4 Go sature la bande passante mémoire de la tablette.
#### RAM nécessaire pour l'export .pte
L'export `.pte` consomme beaucoup de mémoire. Le **sharding** réduit la mémoire de la phase de compilation QNN mais **pas de la quantification** (`prepare_pt2e`) qui charge le modèle entier.
| Modèle | Shards | RAM+Swap pic | Taille .pte | Temps export | Status |
|---|---:|---:|---:|---:|---|
| **Qwen3-0.6B** | 1 | ~20 Go | 660 Mo | ~20 min | **OK** (54 Go RAM) |
| **Qwen3-1.7B** | 1→4 | ~48 Go | 1.7 Go | ~25 min | **OK** (62 Go RAM) |
| **Mistral-Nemo 12B** | 8 | **~250 Go** | 7.4 Go | ~1h40 | **OK** (62 Go RAM + 192 Go swap) |
| Qwen3-4B (estimé) | 4 | ~130 Go | ~3-4 Go | ~1h | Nécessite 128+ Go |
---
## 4. Synthèse comparative
### 4.1 Comparaison à armes égales : Qwen3-0.6B (596M paramètres)
| Framework | Backend | Decode (tok/s) | Status |
|---|---|---:|---|
| **ExecuTorch + QNN** | **NPU Hexagon** | **69.3** | Fonctionnel |
| llama.cpp | CPU NEON (8 threads) | 68.2 | Fonctionnel |
| Genie SDK | GenAiTransformer | CRASH | GQA incompatible |
**Résultat** : NPU et CPU sont **quasi identiques** (~69 tok/s) sur ce petit modèle. La bande passante mémoire LPDDR5X (~77 Go/s) est le facteur limitant pour les deux.
### 4.2 Comparaison : Qwen3-1.7B (1.7B paramètres)
| Framework | Backend | Decode (tok/s) | Status |
|---|---|---:|---|
| **ExecuTorch + QNN** | **NPU Hexagon** | **25.7** | Fonctionnel |
| llama.cpp | CPU NEON (8 threads) | ~15.5 (estimé) | Fonctionnel |
**Résultat** : Le NPU est **~66% plus rapide** que le CPU. C'est le modèle avec le **meilleur rapport qualité/vitesse** pour Kazeia.
### 4.3 Comparaison : Qwen3-4B (4B paramètres)
| Framework | Backend | Decode (tok/s) | Status |
|---|---|---:|---|
| **Genie SDK (AI Hub)** | **NPU Hexagon** | **~19.8** | Fonctionnel |
| llama.cpp | CPU NEON (8 threads) | 15.5 | Fonctionnel |
| llama.cpp | GPU Vulkan | CRASH | Driver incompatible |
**Résultat** : Le NPU est **~27% plus rapide** que le CPU.
### 4.4 Comparaison : Mistral-Nemo 12B (12.25B paramètres - modèle de kazeia.py)
| Framework | Backend | Prefill (tok/s) | Decode (tok/s) | Status |
|---|---|---:|---:|---|
| **ExecuTorch + QNN** | **NPU Hexagon** | **156.9** | **5.1** | Fonctionnel |
| llama.cpp | CPU NEON (8 threads) | 7.25 | 6.05 | Fonctionnel |
**Résultat** : Le NPU est **21x plus rapide en prefill** mais **16% plus lent en decode**. Le modèle de 7.4 Go sature la bande passante mémoire LPDDR5X (~77 Go/s) en decode. Le NPU ne peut pas compenser par du calcul car chaque token nécessite de relire tous les poids.
### 4.5 Tableau récapitulatif complet
| Modèle | Params | CPU decode (tok/s) | NPU decode (tok/s) | NPU prefill (tok/s) | NPU vs CPU (decode) | GPU Vulkan |
|---|---:|---:|---:|---:|---|---|
| **Qwen3-0.6B** | 596M | 68.2 | **69.3** | N/A | +2% | Non testé |
| **Qwen3-1.7B** | 1.7B | ~15.5 | **25.7** | N/A | **+66%** | Non testé |
| **Qwen3-4B** | 4B | 15.5 | **~19.8** (Genie) | N/A | +27% | CRASH |
| **Mistral-Nemo 12B** | 12.25B | 6.05 | 5.1 | **156.9** | **-16%** | Non testé |
| Gemma 3 4B | 3.88B | 16.0 | Non testé | N/A | - | CRASH |
### 4.6 Conclusions clés
1. **Les petits modèles (< 1B)** : NPU ≈ CPU car tous deux limités par la bande passante mémoire (~77 Go/s)
2. **Les modèles moyens (1-2B)** : NPU gagne **+66%** en vitesse — c'est le **sweet spot**
3. **Les modèles plus gros (3-4B)** : NPU gagne **+27%** (Genie SDK, modèles pré-compilés)
4. **Les très gros modèles (12B+)** : NPU **perd en decode** (-16%) car la bande passante mémoire est saturée, mais **domine en prefill** (21x)
5. **Le GPU Vulkan Adreno 830** : inutilisable pour les LLM (driver crash)
5. **Thermal throttling** : le CPU chute à ÷3 après usage prolongé, le NPU est plus stable
6. **Qwen3-1.7B est le meilleur candidat pour Kazeia** : 25.7 tok/s sur NPU, excellent français, réponses empathiques structurées
7. **Mistral-Nemo 12B** (modèle actuel de kazeia.py) : fonctionne sur le NPU mais trop lent en decode (5.1 tok/s) — le modèle est surdimensionné pour la tablette
8. **Qualcomm annonce 29 tok/s** pour Qwen3-4B (notre test : ~20 tok/s → marge d'optimisation)
---
## 5. Analyse et recommandations
### 5.1 Choix du framework pour Kazeia
| Critère | llama.cpp (CPU) | Genie SDK (NPU) | ExecuTorch (NPU) |
|---|---|---|---|
| Facilité de déploiement | Excellente | Moyenne (modèles pré-compilés) | Complexe |
| Modèles supportés | Tous (GGUF) | Limité (AI Hub) | Limité (export RAM) |
| Performance decode (1-4B) | Bonne | Meilleure (+27%) | Meilleure (+66% pour 1.7B) |
| Efficacité batterie | Moyenne | Excellente | Excellente |
| Maintenance | Simple | SDK Qualcomm | SDK Meta + Qualcomm |
### 5.2 Recommandation stratégique
**Approche recommandée** :
- **Production principale** : ExecuTorch + QNN + **Qwen3-1.7B** sur NPU (25.7 tok/s, excellent français, 1.7 Go)
- **Alternative haute qualité** : Genie SDK + Qwen3-4B pré-compilé AI Hub (~19.8 tok/s, réponses plus riches)
- **Fallback universel** : llama.cpp CPU avec n'importe quel modèle GGUF (flexibilité maximale)
- **Non recommandé** : Mistral-Nemo 12B sur tablette (trop lent en decode : 5.1 tok/s NPU / 6 tok/s CPU)
- **R&D** : ExecuTorch + QNN pour les modèles custom (62 Go RAM + swap pour l'export)
### 5.3 Prochaines étapes
#### Court terme (immédiat)
- **Déployer Qwen3-1.7B** via ExecuTorch sur le NPU comme moteur principal de Kazeia (25.7 tok/s)
- Configurer les **stop tokens** Qwen3 (`<|im_end|>`) et le **system prompt** Kazeia
- Désactiver le **mode thinking** pour des réponses plus directes et rapides
- Construire le pipeline complet : prompt Kazeia → NPU → réponse
#### Moyen terme
- Construire une **application Android** intégrant le runtime ExecuTorch + QNN
- Implémenter le RAG localement (embeddings + base de connaissances)
- Tester le **Qwen3-4B via ExecuTorch** sur un serveur cloud (128+ Go RAM) pour comparer
- Optimiser la config Genie (perf_profile, cpu-mask) pour Qwen3-4B AI Hub
#### Long terme
- Fine-tuner un modèle français spécifique pour Kazeia, puis l'exporter via ExecuTorch
- Surveiller l'évolution du support Vulkan pour Adreno dans llama.cpp
- Suivre les nouveaux modèles sur Qualcomm AI Hub
---
## 6. Fichiers et artefacts produits
### Sur le PC (/opt/Kazeia/)
| Chemin | Description |
|---|---|
| `llama.cpp/build-android-cpu/` | Build llama.cpp ARM64 CPU NEON |
| `llama.cpp/build-android-vulkan/` | Build llama.cpp ARM64 Vulkan |
| `llama.cpp/build-native/` | Build llama.cpp x86_64 (outils) |
| `executorch/build-android/` | Build ExecuTorch ARM64 + QNN (llama_main, qnn_executor_runner) |
| `executorch/build-x86/` | Build ExecuTorch x86_64 (PyQnnManagerAdaptor, export Python) |
| `qnn_sdk/qairt/2.37.0.250724/` | Qualcomm QNN SDK 2.37.0 (transformer-composer) |
| `qnn_sdk_242/qairt/2.42.0.251225/` | Qualcomm QNN SDK 2.42.0 (runtime AI Hub + ExecuTorch) |
| `Vulkan-Headers/` | Headers Vulkan v1.3.275 |
| `models_hf/qwen3-4b/` | Qwen3-4B HuggingFace (safetensors) |
| `models_hf/qwen3-0.6b/` | Qwen3-0.6B HuggingFace (safetensors) |
| `models_hf/qwen2.5-3b-instruct/` | Qwen 2.5 3B HuggingFace (safetensors) |
| `models_qnn/qwen3-0_6b-executorch/` | Qwen3-0.6B exporté .pte pour NPU (660 Mo) |
| `models_qnn/qwen3-1_7b-executorch/` | Qwen3-1.7B exporté .pte pour NPU (1.7 Go) |
| `models_qnn/mistral-nemo-executorch/` | Mistral-Nemo 12B exporté .pte pour NPU (7.4 Go) |
| `models_qnn/qwen3_4b-genie-w4a16-.../` | Qwen3-4B pré-compilé AI Hub (4 context binaries) |
| `models_hf/mistral-nemo-instruct/` | Mistral-Nemo-Instruct-2407 HuggingFace (23 Go) |
| `qnn_venv/` | Python 3.10 venv (QNN SDK + ExecuTorch export) |
| `et_venv/` | Python 3.13 venv (non utilisé) |
| `qnn_libs/` | Symlinks bibliothèques système pour QNN |
### Sur la tablette (/data/local/tmp/)
| Chemin | Description |
|---|---|
| `kazeia-bench/llama-bench-cpu` | Benchmark llama.cpp CPU |
| `kazeia-bench/qwen3-4b.gguf` | Qwen3-4B Q4_K_M (2.32 Go) |
| `kazeia-bench/qwen3-06b.gguf` | Qwen3-0.6B Q8_0 (604 Mo) |
| `kazeia-bench/model.gguf` | Gemma 3 4B Q4_K_M (2.31 Go) |
| `kazeia-bench/mistral-nemo.gguf` | Mistral-Nemo 12B IQ4_XS (6.27 Go) |
| `kazeia-npu/` | Genie runner (QNN 2.42) + Qwen3-4B context binaries |
| `kazeia-et/` | ExecuTorch llama_main + QNN libs + Qwen3-0.6B .pte |
| `kazeia-genie06/` | Genie runner (QNN 2.37) + Qwen3-0.6B (test échoué) |
---
## 7. Commandes de référence
### Benchmark CPU sur la tablette
```bash
adb shell "cd /data/local/tmp/kazeia-bench && \
LD_LIBRARY_PATH=. ./llama-bench-cpu -m qwen3-4b.gguf -p 512 -n 128 -t 8"
```
### Test NPU Genie (Qwen3-4B pré-compilé)
```bash
adb shell "cd /data/local/tmp/kazeia-npu && \
LD_LIBRARY_PATH=. ADSP_LIBRARY_PATH=. \
./genie-t2t-run -c genie_config.json -p 'Votre prompt ici'"
```
### Test NPU ExecuTorch (Qwen3-0.6B)
```bash
# Depuis le PC, via le script ExecuTorch :
source /opt/Kazeia/qnn_venv/bin/activate
export QNN_SDK_ROOT=/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225
export LD_LIBRARY_PATH=/opt/Kazeia/qnn_libs:$QNN_SDK_ROOT/lib/x86_64-linux-clang
export PYTHONPATH=/opt/Kazeia:/opt/Kazeia/executorch/build-x86/lib64:$QNN_SDK_ROOT/lib/python
export PATH=/opt/Kazeia/executorch/build-x86/third-party/flatc_ep/bin:$PATH
cd /opt/Kazeia/executorch
python3.10 examples/qualcomm/oss_scripts/llama/llama.py \
-m SM8750 -b build-android --decoder_model qwen3-0_6b \
-s 9e4abcaf --backend htp \
--pre_gen_pte /opt/Kazeia/models_qnn/qwen3-0_6b-executorch \
-a /opt/Kazeia/models_qnn/qwen3-0_6b-executorch \
--prompt "Votre prompt ici"
```
### Test NPU ExecuTorch (Mistral-Nemo 12B)
```bash
# Même commande, remplacer le decoder_model et les chemins :
cd /opt/Kazeia/executorch
python3.10 examples/qualcomm/oss_scripts/llama/llama.py \
-m SM8750 -b build-android --decoder_model mistral_nemo_12b \
-s 9e4abcaf --backend htp \
--pre_gen_pte /opt/Kazeia/models_qnn/mistral-nemo-executorch \
-a /opt/Kazeia/models_qnn/mistral-nemo-executorch \
--prompt "Votre prompt ici"
```
### Cross-compiler llama.cpp pour Android ARM64
```bash
cmake .. \
-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 \
-DCMAKE_BUILD_TYPE=Release -DGGML_OPENMP=OFF -DGGML_CPU_AARCH64=ON -G Ninja
ninja -j$(nproc)
```
### Compiler ExecuTorch + QNN pour Android
```bash
export QNN_SDK_ROOT=/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225
export ANDROID_NDK_ROOT=/opt/Kazeia/android-ndk-r27d
export PYTHON_EXECUTABLE=/opt/Kazeia/qnn_venv/bin/python3.10
cd /opt/Kazeia/executorch
./backends/qualcomm/scripts/build.sh --release
```
### Télécharger un modèle pré-compilé Qualcomm AI Hub
```bash
wget "https://qaihub-public-assets.s3.us-west-2.amazonaws.com/qai-hub-models/models/qwen3_4b/releases/v0.49.1/qwen3_4b-genie-w4a16-qualcomm_snapdragon_8_elite.zip"
```
---
*Rapport généré automatiquement par Claude Code (Opus 4.6)*

View File

@ -0,0 +1,200 @@
# Benchmark Comparatif Root vs Non-Root — OnePlus Pad 3
*Date: 28 mars 2026 — Projet Kazeia*
## Configuration
- **Tablette**: OnePlus Pad 3 (OPD2415), Snapdragon 8 Elite (SM8750)
- **OS**: OxygenOS 16 (Android 16, API 36)
- **RAM**: 16 GB
- **Root**: Magisk v30.6
- **QNN SDK**: 2.42
- **ExecuTorch**: build-android (NDK r27d)
---
## Pipeline Conversationnel Complet
### Sans Root
| Étape | Technologie | Latence | Status |
|-------|------------|---------|--------|
| **VAD** | Énergie RMS | <1ms | Fonctionne |
| **STT** | whisper.cpp (CPU) | ~1500ms | Fonctionne |
| **LLM** | Aucun | - | Mode écho uniquement |
| **TTS** | Android TTS natif | ~200ms | Fonctionne |
| **Total** | - | ~1700ms | Pas de LLM, pas d'IA conversationnelle |
### Avec Root + NPU
| Étape | Technologie | Latence | Status |
|-------|------------|---------|--------|
| **VAD** | Énergie RMS | <1ms | Fonctionne |
| **STT** | whisper.cpp (CPU) | ~1500ms | Fonctionne |
| **LLM** | ExecuTorch Qwen3 (NPU) | ~1-5s (selon longueur) | **NOUVEAU** |
| **TTS** | Android TTS natif | ~200ms | Fonctionne |
| **Total** | - | ~3-7s | **Pipeline complet fonctionnel** |
---
## Benchmarks Détaillés
### STT — Whisper-Base (whisper.cpp CPU)
*Identique avec ou sans root (pas d'accélération NPU pour whisper.cpp)*
| Métrique | Valeur |
|----------|--------|
| Modèle | ggml-base.bin (148 MB) |
| Backend | CPU ARM (Cortex-X925) |
| Chargement | ~100ms |
| Transcription (1-2s audio) | 1000-1500ms |
| RTF moyen | 0.9-1.5 |
| Mel spectrogram | Natif C++ (whisper.cpp) |
| Auto-gain | Oui (normalisation audio) |
### LLM — ExecuTorch + QNN NPU (Root uniquement)
#### Qwen3-0.6B
| Métrique | Valeur |
|----------|--------|
| Modèle | hybrid_llama_qnn.pte (660 MB) |
| Backend | QNN HTP v79 (NPU) |
| Chargement | 0.86s |
| Prefill | 451 tok/s (31ms pour 14 tokens) |
| Génération | **93.15 tok/s** |
| TTFT (Time To First Token) | 31ms |
| RAM | ~698 MB |
| Qualité FR | Basique, réponses courtes |
#### Qwen3-1.7B
| Métrique | Valeur |
|----------|--------|
| Modèle | hybrid_llama_qnn.pte (1.7 GB) |
| Backend | QNN HTP v79 (NPU) |
| Chargement | 1.29s |
| Prefill | 1000 tok/s (27ms pour 27 tokens) |
| Génération | **46.6 tok/s** |
| TTFT | 27ms |
| RAM | ~1712 MB |
| Qualité FR | Bonne, réponses empathiques détaillées |
#### Comparaison modèles
| Modèle | Taille | tok/s | TTFT | RAM | Qualité FR |
|--------|--------|-------|------|-----|------------|
| Qwen3-0.6B | 660 MB | 93 | 31ms | 698 MB | Basique |
| Qwen3-1.7B | 1.7 GB | 46 | 27ms | 1.7 GB | Bonne |
| Mistral-Nemo 12B | 7.4 GB | ~5 | ~200ms | ~8 GB | Excellente |
### LLM — Sans Root
| Métrique | Genie SDK | ExecuTorch QNN |
|----------|-----------|----------------|
| Status | Erreur -7 | Erreur 4000 (libs manquantes) |
| Cause | SDK non installé sur device | `libQnnModelDlc.so` manquant, pas d'accès DSP |
| Résultat | Mode écho | Mode écho |
### TTS
| Métrique | Sans Root | Avec Root |
|----------|-----------|-----------|
| Android TTS | 200ms | 200ms |
| Chatterbox | OOM (1.4 GB) | OOM (1.4 GB)* |
*Chatterbox nécessite optimisation mémoire indépendante du root*
### Monitoring Système
| Métrique | Sans Root | Avec Root |
|----------|-----------|-----------|
| CPU % | Fonctionne | Fonctionne |
| GPU % | Permission denied | Fonctionne via `su` |
| NPU/DSP | N/A | Accès possible |
| RAM | Fonctionne | Fonctionne |
---
## Impact du Root — Synthèse
### Gains mesurés
| Fonctionnalité | Sans Root | Avec Root | Gain |
|----------------|-----------|-----------|------|
| **LLM** | Indisponible | 93 tok/s (0.6B) / 46 tok/s (1.7B) | **∞** |
| **Prefill** | - | 451-1000 tok/s | **∞** |
| **TTFT** | - | 27-31ms | **∞** |
| **Pipeline complet** | Non (écho) | Oui (STT→LLM→TTS) | **Pipeline fonctionnel** |
| **GPU monitoring** | 0% | Fonctionne | Visibilité |
| **Whisper STT** | 1500ms (CPU) | 1500ms (CPU) | 0% (même backend) |
### Ce que le root débloque concrètement
1. **Accès au DSP/NPU via ExecuTorch** → LLM fonctionnel sur NPU
2. **libs QNN chargées correctement**`libQnnModelDlc.so` + Skel dans ADSP_LIBRARY_PATH
3. **SELinux permissive** → pas de blocage d'accès
4. **Boot script** → permissions persistantes au redémarrage
5. **GPU sysfs** → monitoring temps réel
### Ce que le root ne change pas
1. Performances STT (whisper.cpp CPU identique)
2. TTS Android natif (identique)
3. Chatterbox TTS (OOM indépendant du root)
---
## Recommandations pour la Production
### Modèle LLM recommandé
**Qwen3-1.7B** — meilleur compromis qualité/vitesse :
- 46 tok/s suffisant pour conversation temps réel
- Qualité FR bonne pour l'écoute émotionnelle
- 1.7 GB RAM acceptable sur 16 GB device
- TTFT 27ms = réponse quasi instantanée
### Prochaines optimisations
1. **Whisper sur NPU** : le `qnn_whisper_runner` est compilé, réduirait STT de 1500ms à ~50ms
2. **Chatterbox quantifié** : réduire les modèles TTS pour éviter OOM
3. **Streaming LLM** : afficher les tokens au fur et à mesure de la génération
4. **ExecuTorch JNI natif** : intégrer directement en C++ au lieu du subprocess `su`
---
## Procédure de Déploiement (Reproductible)
### 1. Root de la tablette
Voir `GUIDE_ROOT_ONEPLUS_PAD3.md`
### 2. Configuration post-root
```bash
# SELinux permissive
adb shell "su -c 'setenforce 0'"
# Boot script DSP
adb shell "su -c 'mkdir -p /data/adb/service.d && cat > /data/adb/service.d/kazeia_dsp.sh << EOF
#!/system/bin/sh
chmod 666 /dev/fastrpc-cdsp
chmod 666 /dev/fastrpc-cdsp-secure
setenforce 0
EOF
chmod 755 /data/adb/service.d/kazeia_dsp.sh'"
```
### 3. Déploiement ExecuTorch
Voir `DEPLOY_EXECUTORCH_NPU.md`
### 4. Déploiement app Kazeia
```bash
cd /opt/Kazeia/kazeia-android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
---
*Projet Kazeia — Damien Micottis & Richard Loyer*

138
DEPLOY_EXECUTORCH_NPU.md Normal file
View File

@ -0,0 +1,138 @@
# Déploiement ExecuTorch + QNN NPU — OnePlus Pad 3
*Procédure validée le 28 mars 2026*
## Prérequis
- Tablette rootée (Magisk)
- SELinux permissive (`su -c 'setenforce 0'`)
- DSP permissions ouvertes (boot script `/data/adb/service.d/kazeia_dsp.sh`)
- QNN SDK 2.42 installé sur le PC : `/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225/`
- ExecuTorch compilé pour Android ARM64 : `/opt/Kazeia/executorch/build-android/`
## Fichiers requis (TOUS obligatoires)
| Fichier | Source | Rôle |
|---------|--------|------|
| `qnn_llama_runner` | `executorch/build-android/examples/qualcomm/oss_scripts/llama/` | Runner LLM |
| `libqnn_executorch_backend.so` | `executorch/build-android/backends/qualcomm/` | Backend ExecuTorch ↔ QNN |
| `libQnnHtp.so` | `qnn_sdk_242/.../lib/aarch64-android/` | QNN HTP runtime |
| `libQnnHtpV79Stub.so` | `qnn_sdk_242/.../lib/aarch64-android/` | Stub CPU → DSP |
| `libQnnHtpV79Skel.so` | `qnn_sdk_242/.../lib/hexagon-v79/unsigned/` | Skel DSP (hexagon) |
| `libQnnHtpPrepare.so` | `qnn_sdk_242/.../lib/aarch64-android/` | Préparation graphe HTP |
| `libQnnSystem.so` | `qnn_sdk_242/.../lib/aarch64-android/` | Système QNN |
| `libQnnModelDlc.so` | `qnn_sdk_242/.../lib/aarch64-android/` | Chargement modèles DLC |
| `libQnnHtpNetRunExtensions.so` | `qnn_sdk_242/.../lib/aarch64-android/` | Extensions réseau HTP |
| `hybrid_llama_qnn.pte` | `models_qnn/qwen3-*-executorch/` | Modèle ExecuTorch |
| `tokenizer.json` | `models_qnn/qwen3-*-executorch/` | Tokenizer HuggingFace |
**IMPORTANT** : Les libs Skel DOIVENT venir du même SDK que celui utilisé pour compiler le .pte. Ne PAS utiliser les libs vendor de la tablette (`/vendor/lib64/hw/audio/`).
## Script de déploiement
```bash
#!/bin/bash
QNN="/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225"
ET="/opt/Kazeia/executorch"
DIR="/data/local/tmp/kazeia-et"
MODEL_DIR="/opt/Kazeia/models_qnn/qwen3-0_6b-executorch" # ou qwen3-1_7b-executorch
# Créer répertoire
adb shell "rm -rf $DIR && mkdir -p $DIR/outputs"
# Libs QNN SDK 2.42
adb push $QNN/lib/aarch64-android/libQnnHtp.so $DIR/
adb push $QNN/lib/aarch64-android/libQnnHtpV79Stub.so $DIR/
adb push $QNN/lib/hexagon-v79/unsigned/libQnnHtpV79Skel.so $DIR/
adb push $QNN/lib/aarch64-android/libQnnHtpPrepare.so $DIR/
adb push $QNN/lib/aarch64-android/libQnnSystem.so $DIR/
adb push $QNN/lib/aarch64-android/libQnnModelDlc.so $DIR/
adb push $QNN/lib/aarch64-android/libQnnHtpNetRunExtensions.so $DIR/
# ExecuTorch
adb push $ET/build-android/backends/qualcomm/libqnn_executorch_backend.so $DIR/
adb push $ET/build-android/examples/qualcomm/oss_scripts/llama/qnn_llama_runner $DIR/
# Modèle + Tokenizer
adb push $MODEL_DIR/hybrid_llama_qnn.pte $DIR/
adb push $MODEL_DIR/tokenizer.json $DIR/
# Permissions
adb shell "chmod +x $DIR/qnn_llama_runner"
```
## Exécution
```bash
adb shell "su -c 'cd /data/local/tmp/kazeia-et && \
export LD_LIBRARY_PATH=/data/local/tmp/kazeia-et && \
export ADSP_LIBRARY_PATH=/data/local/tmp/kazeia-et && \
./qnn_llama_runner \
--model_path hybrid_llama_qnn.pte \
--tokenizer_path tokenizer.json \
--decoder_model_version qwen3 \
--output_path outputs/outputs.txt \
--performance_output_path outputs/perf.txt \
--shared_buffer \
--prompt \"Votre prompt ici\" \
--system_prompt \"Tu es Kazeia, compagnon écoute émotionnelle.\" \
--temperature 0.7 \
--seq_len 256 \
--eval_mode 1'"
```
### Paramètres clés
| Paramètre | Valeur | Description |
|-----------|--------|-------------|
| `--decoder_model_version` | `qwen3` | Template de chat Qwen3 |
| `--eval_mode` | `1` | Mode hybride (prefill + KV cache) |
| `--shared_buffer` | (flag) | **OBLIGATOIRE** — mémoire partagée CPU↔DSP |
| `--output_path` | chemin | Fichier de sortie texte |
| `--temperature` | 0.0-1.0 | 0 = déterministe, 0.7 = créatif |
| `--seq_len` | 128-4096 | Longueur max (prompt + réponse) |
## Performances mesurées
### Qwen3-0.6B (660 MB)
- **Chargement** : 0.86s
- **Prefill** : 31ms (451 tok/s)
- **Génération** : 93.15 tok/s
- **Temps au premier token** : 31ms
- **RAM** : ~698 MB
### Qwen3-1.7B (1.7 GB)
- **Génération** : ~25.7 tok/s (benchmark mars 2026)
### Mistral-Nemo 12B (7.4 GB)
- **Génération** : ~5.1 tok/s (benchmark mars 2026)
## Modèles disponibles
| Modèle | Fichier .pte | Taille | Qualité FR |
|--------|-------------|--------|------------|
| Qwen3-0.6B | `qwen3-0_6b-executorch/hybrid_llama_qnn.pte` | 660 MB | Basique |
| Qwen3-1.7B | `qwen3-1_7b-executorch/hybrid_llama_qnn.pte` | 1.7 GB | Bonne |
| Mistral-Nemo 12B | `mistral-nemo-executorch/hybrid_llama_qnn.pte` | 7.4 GB | Excellente |
## Erreurs courantes
### `loadRemoteSymbols failed with err 4000`
- **Cause** : Libs Skel manquantes ou incompatibles
- **Fix** : Vérifier que `libQnnHtpV79Skel.so` vient du SDK 2.42 (hexagon-v79/unsigned/), pas du vendor
### `SoC model (SnapdragonModel) is unknown`
- **Cause** : Utilisation des libs vendor au lieu des libs SDK
- **Fix** : Ne PAS utiliser `/vendor/lib64/hw/audio/libQnn*.so`
### Runner se termine sans générer de texte
- **Cause** : `--shared_buffer` et `--output_path` manquants
- **Fix** : Ajouter les deux paramètres
### `Failed to create transport for device`
- **Cause** : `libQnnModelDlc.so` manquant
- **Fix** : Copier depuis `qnn_sdk_242/.../lib/aarch64-android/`
---
*Projet Kazeia — Damien Micottis & Richard Loyer*

913
DOCUMENTATION_KAZEIA.txt Normal file
View File

@ -0,0 +1,913 @@
################################################################################
# #
# DOCUMENTATION COMPLETE #
# PROJET KAZEIA #
# #
# Chatbot de Soutien Emotionnel #
# #
# Developpe par: Damien Micottis et Richard Loyer #
# Master UTBM 2024/2025 #
# #
################################################################################
Date de creation du document : 22 janvier 2026
Version du projet : 1.0
################################################################################
TABLE DES MATIERES
################################################################################
1. PRESENTATION GENERALE
1.1 Description du projet
1.2 Objectifs
1.3 Public cible
2. ARCHITECTURE TECHNIQUE
2.1 Vue d'ensemble
2.2 Stack technologique
2.3 Structure des fichiers
2.4 Diagramme des composants
3. INSTALLATION ET DEPLOIEMENT
3.1 Prerequisites systeme
3.2 Installation locale
3.3 Installation Docker
3.4 Configuration
4. BASE DE DONNEES
4.1 Schema de la base
4.2 Tables detaillees
4.3 Relations
5. API ET ENDPOINTS
5.1 Routes disponibles
5.2 Format des requetes/reponses
5.3 Authentification
6. MODULES FONCTIONNELS
6.1 Gestion des conversations
6.2 Systeme RAG
6.3 Questionnaire PHQ-9
6.4 Synthese vocale (TTS)
6.5 Transcription (STT)
6.6 Resume de texte
7. CONFIGURATION DETAILLEE
7.1 Parametres disponibles
7.2 Configuration des modeles
7.3 Variables d'environnement
8. SECURITE
8.1 Authentification
8.2 Protection des donnees
8.3 Recommandations
9. GUIDE D'UTILISATION
9.1 Interface utilisateur
9.2 Interface medecin
9.3 Commandes CLI
10. PERFORMANCES ET OPTIMISATION
10.1 Metriques actuelles
10.2 Recommandations vLLM
10.3 Bonnes pratiques
11. MAINTENANCE ET EVOLUTION
11.1 Logs et monitoring
11.2 Sauvegarde
11.3 Roadmap
================================================================================
1. PRESENTATION GENERALE
================================================================================
1.1 DESCRIPTION DU PROJET
-------------------------
Kazeia est un chatbot conversationnel d'accompagnement emotionnel propulse par
l'intelligence artificielle. Il est concu pour fournir une ecoute empathique,
une evaluation de la sante mentale via le questionnaire PHQ-9, et un soutien
personnalise aux utilisateurs en langue francaise.
Le projet combine plusieurs technologies d'IA de pointe :
- Generation de texte par LLM (Qwen)
- Recuperation augmentee de generation (RAG)
- Synthese vocale avec clonage de voix (Auralis/XTTS)
- Reconnaissance vocale (Whisper)
- Resume automatique (mBARThez)
1.2 OBJECTIFS
-------------
- Offrir un espace d'ecoute bienveillant et non-jugeant
- Evaluer periodiquement l'etat emotionnel des utilisateurs (PHQ-9)
- Fournir des reponses empathiques et contextualisees
- Supporter l'interaction vocale bidirectionnelle
- Permettre aux professionnels de sante de suivre leurs patients
1.3 PUBLIC CIBLE
----------------
- Utilisateurs : Personnes cherchant un soutien emotionnel quotidien
- Medecins/Professionnels : Suivi des patients et analyse des donnees PHQ-9
================================================================================
2. ARCHITECTURE TECHNIQUE
================================================================================
2.1 VUE D'ENSEMBLE
------------------
+------------------+
| Interface Web |
| (Flask/HTTPS) |
+--------+---------+
|
+--------------+--------------+
| | |
+---------v----+ +-------v------+ +----v---------+
| Conversation | | RAG | | Audio |
| Manager | | System | | Pipeline |
+------+-------+ +------+-------+ +------+-------+
| | |
+------v-------+ +------v-------+ +------v-------+
| Qwen | | ChromaDB | | Whisper/TTS |
| LLM | | Vectors | | Auralis |
+--------------+ +--------------+ +--------------+
| | |
+--------------+--------------+
|
+--------v---------+
| SQLite |
| Database |
+------------------+
2.2 STACK TECHNOLOGIQUE
-----------------------
+----------------------+----------------------------------+----------------+
| Composant | Technologie | Version |
+----------------------+----------------------------------+----------------+
| Langage | Python | 3.10 |
| Framework Web | Flask | >= 3.1.1 |
| Base de donnees | SQLite | 3.x |
| Base vectorielle | ChromaDB | >= 1.0.12 |
| LLM Principal | Qwen (HuggingFace) | Local |
| Embeddings | sentence-camembert-base | - |
| Cross-Encoder | crossencoder-camembert-base | - |
| TTS | Auralis/XTTS v2 | >= 0.2.8 |
| STT | OpenAI Whisper | >= 20240930 |
| Resume | mBARThez | Local |
| Securite | Werkzeug | >= 3.1.3 |
| SSL | pyOpenSSL | >= 25.1.0 |
+----------------------+----------------------------------+----------------+
2.3 STRUCTURE DES FICHIERS
--------------------------
/opt/kazeia/
|
|-- main.py # Point d'entree principal
|-- config.py # Configuration centralisee (161 lignes)
|
|-- Modules principaux:
| |-- conversation_manager.py # Gestion des conversations (649 lignes)
| |-- web_interface.py # Interface Flask (1,882 lignes)
| |-- database_manager.py # Acces base de donnees (541 lignes)
| |-- rag.py # Systeme RAG (191 lignes)
| |-- phq9_manager.py # Questionnaire PHQ-9 (95 lignes)
| |-- tts.py # Synthese vocale (226 lignes)
| |-- transcription.py # Transcription audio (50 lignes)
| |-- summarizer.py # Resume de texte (151 lignes)
|
|-- Utilitaires:
| |-- user_management_cli.py # CLI gestion utilisateurs
| |-- cli_chatbot.py # Interface chatbot CLI
| |-- load_pdf.py # Chargement PDF dans ChromaDB
| |-- load_fiches_patients.py # Chargement fiches patients
| |-- voice_cloning.py # Clonage vocal
|
|-- Donnees et configuration:
| |-- phq-9_questions.json # Questions PHQ-9
| |-- requirements.txt # Dependances Python
| |-- Dockerfile # Configuration Docker
| |-- README.txt # Instructions basiques
|
|-- Repertoires:
| |-- database/ # Base SQLite (~61 Ko)
| |-- chroma_db/ # Base vectorielle (~35 Mo)
| |-- model/ # Modeles pre-entraines
| | |-- kazeia/ # Modele Qwen fine-tune
| | |-- mbarthez/ # Modele de resume
| |-- auralis/ # Echantillons vocaux (8 fichiers, ~130 Mo)
| |-- static/ # Ressources web
| |-- tts_audio_auralis/ # Audio genere
| |-- fiches_patients/ # Donnees patients
================================================================================
3. INSTALLATION ET DEPLOIEMENT
================================================================================
3.1 PREREQUISITES SYSTEME
-------------------------
Materiel recommande :
- CPU : 4+ coeurs
- RAM : 16 Go minimum (32 Go recommande)
- GPU : NVIDIA avec 8+ Go VRAM (optionnel mais recommande)
- Stockage : 20 Go minimum
Logiciels requis :
- Python 3.10
- pip (gestionnaire de paquets)
- PortAudio (pour l'audio)
- CUDA Toolkit (si GPU NVIDIA)
Pour Fedora/RHEL :
sudo dnf install portaudio-devel python3-whisper.noarch g++ git
Pour Ubuntu/Debian :
sudo apt install portaudio19-dev python3-pip git build-essential
3.2 INSTALLATION LOCALE
-----------------------
Etape 1 : Cloner ou telecharger le projet
cd /opt
# Copier les fichiers du projet dans /opt/kazeia
Etape 2 : Creer l'environnement virtuel
python3.10 -m venv kazeia_env
source kazeia_env/bin/activate
Etape 3 : Installer les dependances
pip install --upgrade pip
pip install -r requirements.txt
Etape 4 : Telecharger les modeles (si non inclus)
# Modele Qwen : placer dans ./model/kazeia/
# Modele mBARThez : placer dans ./model/mbarthez/
Etape 5 : Initialiser la base ChromaDB (si necessaire)
python load_pdf.py # Charger les documents PDF
python load_fiches_patients.py # Charger les fiches patients
Etape 6 : Creer un utilisateur
python user_management_cli.py create --username admin --type doctor
# Suivre les instructions pour le mot de passe
Etape 7 : Lancer l'application
python main.py
Etape 8 : Acceder a l'interface
Ouvrir https://127.0.0.1:5000 dans un navigateur
(Accepter l'avertissement de certificat auto-signe)
3.3 INSTALLATION DOCKER
-----------------------
Dockerfile fourni :
FROM python:3.10.17-slim-bullseye AS chatbot_project
RUN apt-get update
RUN apt-get install python3-whisper -y
RUN pip install --upgrade pip
RUN pip install auralis==0.2.8.post2
RUN pip install Flask pyOpenSSL
RUN pip install openai-whisper
RUN pip install sentence-transformers
RUN pip install accelerate
RUN pip install chromadb
RUN pip install numpy langchain PyMuPDF hf_xet
RUN pip install SentencePiece
Construction et execution :
docker build -t kazeia .
docker run -p 5000:5000 -v ./model:/app/model -v ./database:/app/database kazeia
3.4 CONFIGURATION
-----------------
Le fichier config.py centralise toute la configuration.
Voir la section 7 pour les details complets.
================================================================================
4. BASE DE DONNEES
================================================================================
4.1 SCHEMA DE LA BASE
---------------------
Type : SQLite 3
Fichier : ./database/chatbot_users.db
Taille : ~61 Ko (variable selon utilisation)
4.2 TABLES DETAILLEES
---------------------
TABLE: users
+---------------+---------+------------------------------------------------+
| Colonne | Type | Description |
+---------------+---------+------------------------------------------------+
| id | INTEGER | Cle primaire auto-incrementee |
| username | TEXT | Nom d'utilisateur unique (NOT NULL) |
| password_hash | TEXT | Hash du mot de passe (Werkzeug) |
| user_type | TEXT | 'user' ou 'doctor' (defaut: 'user') |
| created_at | TIMESTAMP| Date de creation |
+---------------+---------+------------------------------------------------+
TABLE: user_profiles
+---------------+---------+------------------------------------------------+
| Colonne | Type | Description |
+---------------+---------+------------------------------------------------+
| user_id | INTEGER | Cle primaire, FK vers users.id |
| prenom | TEXT | Prenom de l'utilisateur |
| nom_famille | TEXT | Nom de famille |
| age | INTEGER | Age de l'utilisateur |
| updated_at | TIMESTAMP| Derniere mise a jour |
+---------------+---------+------------------------------------------------+
TABLE: conversation_history
+-------------------+---------+----------------------------------------------+
| Colonne | Type | Description |
+-------------------+---------+----------------------------------------------+
| id | INTEGER | Cle primaire auto-incrementee |
| user_id | INTEGER | FK vers users.id (NOT NULL) |
| user_message | TEXT | Message de l'utilisateur |
| chatbot_response | TEXT | Reponse du chatbot |
| conversation_state| TEXT | Etat de la conversation |
| turn_type | TEXT | Type de tour (defaut: 'other') |
| timestamp | TIMESTAMP| Horodatage |
+-------------------+---------+----------------------------------------------+
Types de tours (turn_type) :
- conversation : Echange normal
- profile_collection : Collecte de profil
- phq9_proposal : Proposition PHQ-9
- phq9_question : Question PHQ-9
- phq9_result : Resultat PHQ-9
- system_init : Initialisation systeme
- system_error : Erreur systeme
TABLE: phq9_assessments
+------------------+---------+----------------------------------------------+
| Colonne | Type | Description |
+------------------+---------+----------------------------------------------+
| id | INTEGER | Cle primaire auto-incrementee |
| user_id | INTEGER | FK vers users.id (NOT NULL) |
| score | INTEGER | Score PHQ-9 (0-27) |
| assessment_date | TIMESTAMP| Date de l'evaluation |
| answers_json | TEXT | Reponses serialisees en JSON |
+------------------+---------+----------------------------------------------+
4.3 RELATIONS
-------------
users (1) ----< (N) user_profiles (1:1 en pratique)
users (1) ----< (N) conversation_history
users (1) ----< (N) phq9_assessments
Suppression en cascade activee : la suppression d'un utilisateur supprime
automatiquement son profil, son historique et ses evaluations PHQ-9.
================================================================================
5. API ET ENDPOINTS
================================================================================
5.1 ROUTES DISPONIBLES
----------------------
+----------------------------------+--------+----------------------------------+
| Route | Methode| Description |
+----------------------------------+--------+----------------------------------+
| / | GET | Page d'accueil/redirection |
| /login | GET/POST| Connexion utilisateur |
| /logout | POST | Deconnexion |
| /get_auralis_voices | GET | Liste des voix disponibles |
| /transcribe_audio_only | POST | Transcription audio -> texte |
| /generate_summary/<patient_id> | GET | Resume de conversation |
| /get_conversation/<patient_id> | GET | Historique de conversation |
| /get_chatbot_response | POST | Obtenir reponse du chatbot |
| /patient/<patient_id> | GET | Details d'un patient |
| /rag_query | POST | Requete RAG directe |
+----------------------------------+--------+----------------------------------+
5.2 FORMAT DES REQUETES/REPONSES
--------------------------------
POST /login
-----------
Requete (form-data) :
username: string
password: string
Reponse (redirection) :
Succes -> / (page principale)
Echec -> /login (avec message d'erreur)
POST /get_chatbot_response
--------------------------
Requete (JSON) :
{
"message": "Bonjour, comment allez-vous ?",
"voice_id": "damien" // optionnel
}
Reponse (JSON) :
{
"response_text": "Bonjour ! Je vais bien, merci...",
"audio_url": "/static/tts_audio_auralis/response_123.wav",
"phq9_question_data": null // ou objet question si PHQ-9 actif
}
POST /transcribe_audio_only
---------------------------
Requete (multipart/form-data) :
audio_file: fichier audio (WAV, MP3, etc.)
Reponse (JSON) :
{
"transcription": "Texte transcrit de l'audio"
}
GET /get_conversation/<patient_id>
----------------------------------
Reponse (JSON) :
{
"conversation": [
{
"id": 1,
"user_message": "...",
"chatbot_response": "...",
"timestamp": "2026-01-22 10:30:00",
"turn_type": "conversation"
},
...
]
}
5.3 AUTHENTIFICATION
--------------------
Type : Session Flask
- Les sessions sont stockees cote serveur
- Cookie de session envoye au client
- Decorateur @login_required pour proteger les routes
Exemple d'utilisation :
@app.route('/protected')
@login_required
def protected_route():
# Acces a session['user_id'], session['username']
pass
================================================================================
6. MODULES FONCTIONNELS
================================================================================
6.1 GESTION DES CONVERSATIONS (conversation_manager.py)
-------------------------------------------------------
Machine a etats :
INITIAL_SESSION_MESSAGE -> Debut de session
STATE_PROFILE_COLLECT_PRENOM -> Collecte prenom
STATE_PROFILE_COLLECT_NOM -> Collecte nom
STATE_PROFILE_COLLECT_AGE -> Collecte age
STATE_PHQ9_PROPOSAL -> Proposition questionnaire
STATE_PHQ9_ASKING_QUESTION -> Questions PHQ-9
STATE_MAIN_CHAT -> Conversation normale
Flux typique :
1. Utilisateur se connecte
2. Si profil incomplet -> collecte d'informations
3. Si PHQ-9 du (> 7 jours) -> proposition questionnaire
4. Sinon -> conversation normale avec RAG
Prompt systeme du LLM :
- Role d'ecoute empathique
- Validation des emotions
- Reponses concises (1-2 paragraphes)
- Redirection vers 3114 si risque suicidaire
- Pas de diagnostic medical
6.2 SYSTEME RAG (rag.py)
------------------------
Composants :
- Embedding : sentence-camembert-base (SentenceTransformers)
- Cross-Encoder : crossencoder-camembert-base-mmarcoFR
- Vector Store : ChromaDB
Pipeline RAG :
1. Requete utilisateur
2. Recherche semantique (N_INITIAL_RETRIEVAL = 5 documents)
3. Re-ranking par cross-encoder
4. Selection des meilleurs documents (N_RERANKED_CONTEXT = 1)
5. Injection dans le contexte du LLM
Collections ChromaDB :
- health_knowledge : Documents PDF sante
- patients : Fiches patients medicales
6.3 QUESTIONNAIRE PHQ-9 (phq9_manager.py)
-----------------------------------------
Le PHQ-9 (Patient Health Questionnaire-9) est un outil valide cliniquement
pour le depistage de la depression.
Questions :
Q1: Peu d'interet ou de plaisir a faire les choses
Q2: Etre triste, deprime(e) ou desespere(e)
Q3: Difficultes a s'endormir ou a rester endormi(e), ou dormir trop
Q4: Se sentir fatigue(e) ou manquer d'energie
Q5: Avoir peu d'appetit ou manger trop
Q6: Avoir une mauvaise opinion de soi-meme
Q7: Avoir du mal a se concentrer
Q8: Bouger ou parler lentement / etre agite(e)
Q9: Penser qu'il vaudrait mieux mourir ou envisager de se faire du mal
Reponses possibles (pour chaque question) :
- Jamais (0 points)
- Quelques jours (1 point)
- Plus de la moitie du temps (2 points)
- Presque tous les jours (3 points)
Interpretation du score total (0-27) :
0-4 : Depression minimale
5-9 : Depression legere
10-14 : Depression moderee
15-19 : Depression moderement severe
20-27 : Depression severe
Configuration :
- PHQ9_ENABLED : Active/desactive le questionnaire
- PHQ9_ASSESSMENT_INTERVAL_DAYS : Intervalle entre evaluations (defaut: 7)
6.4 SYNTHESE VOCALE (tts.py)
----------------------------
Technologie : Auralis / XTTS v2
Modele : AstraMindAI/xttsv2
Voix disponibles (8 profils pre-entraines) :
- amir.wav
- damien.wav
- didier.wav
- elodie.wav
- jerome.wav
- richard.wav
- sid.wav
- zelda.wav
Fonctionnalites :
- Synthese text-to-speech en francais
- Clonage de voix a partir d'echantillons WAV
- Support GPU (CUDA) pour acceleration
- Sortie au format WAV
6.5 TRANSCRIPTION (transcription.py)
------------------------------------
Technologie : OpenAI Whisper
Modeles disponibles : small, medium, large
Langue : Francais (fr)
Utilisation :
audio_file -> Whisper -> texte transcrit
6.6 RESUME DE TEXTE (summarizer.py)
-----------------------------------
Technologie : mBARThez (HuggingFace Transformers)
Approche : Map-Reduce pour les longs textes
Parametres :
- MAX_TOKENS_PER_SUMMARY_CHUNK : 480 tokens par chunk
- MAX_NEW_TOKENS_SUMMARY_CHUNK : 200 tokens generes
- MAX_TOKENS_OVERLAP : 100 tokens de chevauchement
================================================================================
7. CONFIGURATION DETAILLEE
================================================================================
7.1 PARAMETRES DISPONIBLES (config.py)
--------------------------------------
# --- Logging ---
LOGGING_LEVEL = logging.DEBUG
LOGGING_FORMAT = '%(asctime)s - %(levelname)s - %(module)s - %(message)s'
LOG_FILENAME = "conversation_log.txt"
THIRD_PARTY_LOGGERS = ["transformers", "sentence_transformers", "chromadb",
"httpx", "auralis"]
THIRD_PARTY_LOGGING_LEVEL = logging.WARNING
# --- Base de donnees ---
DATABASE_DIRECTORY = Path("./database")
DATABASE_FILENAME = "chatbot_users.db"
DATABASE_FILE_PATH = DATABASE_DIRECTORY / DATABASE_FILENAME
# --- PHQ-9 ---
PHQ9_QUESTIONS_FILE = Path("./phq-9_questions.json")
PHQ9_ASSESSMENT_INTERVAL_DAYS = 7
PHQ9_ENABLED = True
# --- Whisper (STT) ---
WHISPER_MODEL_NAME = "small" # small, medium, large
WHISPER_LANGUAGE = "fr"
# --- RAG ---
CHROMA_DB_PATH = "./chroma_db"
EMBEDDING_MODEL_NAME = 'dangvantuan/sentence-camembert-base'
CROSS_ENCODER_MODEL_NAME = 'antoinelouis/crossencoder-camembert-base-mmarcoFR'
RAG_RERANK_SCORE_THRESHOLD = 0.1
N_INITIAL_RETRIEVAL = 5
N_RERANKED_CONTEXT = 1
MAX_CONTEXT_TOKENS = 2048
# --- Generateur LLM ---
USE_CUDA_FOR_GENERATOR = True if torch.cuda.is_available() else False
GENERATOR_MODEL_TYPE = "hf" # "hf" ou "gguf"
GENERATOR_MODEL_NAME_HF = "./model/kazeia"
GENERATOR_MODEL_NAME_GGUF = "./gguf/qwen3-1.7B_claire.gguf"
GGUF_GPU_LAYERS = 50 if USE_CUDA_FOR_GENERATOR else 0
MAX_NEW_TOKENS = 300
MAX_NEW_TOKENS_GREETING = 300
REPETITION_PENALTY = 1.2
NO_REPEAT_NGRAM_SIZE = 3
# --- TTS Auralis ---
AURALIS_XTTS_MODEL_NAME = "AstraMindAI/xttsv2"
AURALIS_GPT_MODEL_NAME = "AstraMindAI/xtts2-gpt"
AURALIS_VOICES_DIR = Path("./auralis")
AURALIS_SPEAKER_WAV_DEFAULT = "auralis/damien.wav"
AURALIS_LANGUAGE = "fr"
AURALIS_AUDIO_OUTPUT_DIR = Path("./static/tts_audio_auralis")
AURALIS_USE_CUDA = True
# --- Summarizer ---
SUMMARIZER_MODEL_TYPE = "hf"
SUMMARIZER_MODEL_NAME_HF = "./model/mbarthez"
SUMMARIZER_USE_CUDA = True
MAX_TOKENS_PER_SUMMARY_CHUNK = 480
MAX_NEW_TOKENS_SUMMARY_CHUNK = 200
MAX_TOKENS_OVERLAP = 100
# --- Historique conversation ---
CONVERSATION_CONTEXT_TURNS = 5
MAX_TOKENS_FOR_HISTORY_LLM = 700
# --- Flask ---
FLASK_HOST = '0.0.0.0'
FLASK_PORT = 5000
FLASK_DEBUG = False
FLASK_USE_RELOADER = False
FLASK_USE_SSL = True
FLASK_STATIC_FOLDER = 'static'
================================================================================
8. SECURITE
================================================================================
8.1 AUTHENTIFICATION
--------------------
Implemented :
(+) Hachage des mots de passe avec Werkzeug (PBKDF2-SHA256)
(+) Sessions Flask securisees
(+) Decorateur @login_required pour routes protegees
(+) Types d'utilisateurs (user/doctor) avec permissions
8.2 PROTECTION DES DONNEES
--------------------------
Implemented :
(+) HTTPS/SSL natif (mode adhoc ou certificats personnalises)
(+) Requetes SQL parametrees (prevention injection SQL)
(+) Validation des types d'utilisateurs
A ameliorer :
(-) Donnees en clair dans SQLite (recommander chiffrement)
(-) Pas de rate limiting
(-) Pas de validation CSRF
(-) Certificats auto-signes en production
8.3 RECOMMANDATIONS
-------------------
PRIORITE HAUTE :
[ ] Implementer rate limiting (Flask-Limiter)
[ ] Ajouter protection CSRF (Flask-WTF)
[ ] Utiliser certificats SSL valides (Let's Encrypt)
[ ] Chiffrer les donnees sensibles en base
PRIORITE MOYENNE :
[ ] Ajouter logs d'audit de securite
[ ] Implementer expiration de session
[ ] Valider/sanitizer toutes les entrees
================================================================================
9. GUIDE D'UTILISATION
================================================================================
9.1 INTERFACE UTILISATEUR
-------------------------
Acces : https://127.0.0.1:5000/login
1. Connexion
- Entrer nom d'utilisateur et mot de passe
- Cliquer sur "Se connecter"
2. Premier usage
- Le chatbot demande prenom, nom, age
- Repondre aux questions de profil
3. Questionnaire PHQ-9
- Propose periodiquement (tous les 7 jours)
- Repondre "Oui" pour participer, "Non" pour refuser
- Selectionner les reponses pour chaque question
4. Conversation
- Taper un message dans la zone de texte
- Ou utiliser le microphone pour parler
- Le chatbot repond par texte et audio
9.2 INTERFACE MEDECIN
---------------------
Les utilisateurs de type "doctor" ont acces a :
- Liste des patients
- Historique des conversations
- Scores PHQ-9 avec evolution
- Generation de resumes
9.3 COMMANDES CLI
-----------------
Gestion des utilisateurs (user_management_cli.py) :
# Creer un utilisateur
python user_management_cli.py create --username jean --type user
# Creer un medecin
python user_management_cli.py create --username dr_martin --type doctor
# Lister les utilisateurs
python user_management_cli.py list
# Modifier le type d'un utilisateur
python user_management_cli.py update --username jean --type doctor
# Supprimer un utilisateur
python user_management_cli.py delete --username jean
Chatbot en ligne de commande (cli_chatbot.py) :
python cli_chatbot.py --username jean
================================================================================
10. PERFORMANCES ET OPTIMISATION
================================================================================
10.1 METRIQUES ACTUELLES
------------------------
Temps de reponse estime (configuration actuelle) :
+------------------------+----------+--------+
| Composant | Temps | % |
+------------------------+----------+--------+
| Inference LLM | 1500 ms | 85% |
| RAG retrieval | 100 ms | 6% |
| Overhead Python | 50 ms | 3% |
| DB queries | 10 ms | 0.6% |
| Web/routing | 5 ms | 0.3% |
+------------------------+----------+--------+
| TOTAL | ~1665 ms | 100% |
+------------------------+----------+--------+
10.2 RECOMMANDATIONS vLLM
-------------------------
vLLM est un moteur d'inference haute performance qui pourrait reduire
significativement la latence. Il est DEJA INSTALLE (dependance Auralis).
Gains attendus avec vLLM :
+---------------------------+-------------------+-------------------+
| Metrique | Actuel (HF) | vLLM |
+---------------------------+-------------------+-------------------+
| Throughput (tokens/s) | 30-50 | 100-200 |
| Latence 1ere reponse | 1000-2000 ms | 200-500 ms |
| Memoire GPU | 4-6 GB | 3-4 GB |
| Utilisateurs simultanes | 1-2 | 5-10+ |
+---------------------------+-------------------+-------------------+
Implementation suggere :
from vllm import LLM, SamplingParams
vllm_model = LLM(
model="./model/kazeia",
trust_remote_code=True,
dtype="float16",
gpu_memory_utilization=0.8
)
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.8,
max_tokens=300,
repetition_penalty=1.2
)
Effort d'integration : 2-3 heures
Gain de performance : ~68% reduction de latence
10.3 BONNES PRATIQUES
---------------------
[ ] Utiliser vLLM pour l'inference LLM
[ ] Implementer un cache Redis pour les embeddings frequents
[ ] Quantizer le modele en INT8 pour economiser la memoire
[ ] Utiliser FastAPI au lieu de Flask pour l'async natif
[ ] Configurer Celery pour les taches longues (TTS, resume)
================================================================================
11. MAINTENANCE ET EVOLUTION
================================================================================
11.1 LOGS ET MONITORING
-----------------------
Fichiers de log :
- conversation_log.txt : Log principal (~700 Ko)
- Format : timestamp - level - module - message
Niveaux de log :
- DEBUG : Details techniques
- INFO : Operations normales
- WARNING : Problemes non-bloquants
- ERROR : Erreurs necessitant attention
11.2 SAUVEGARDE
---------------
Elements a sauvegarder :
- ./database/chatbot_users.db (base SQLite)
- ./chroma_db/ (base vectorielle)
- ./model/ (modeles pre-entraines)
- ./auralis/ (echantillons vocaux)
- config.py (configuration)
Script de sauvegarde suggere :
#!/bin/bash
DATE=$(date +%Y%m%d)
tar -czvf kazeia_backup_$DATE.tar.gz \
database/ chroma_db/ config.py auralis/
11.3 ROADMAP
------------
COURT TERME (1-2 semaines) :
[ ] Integrer vLLM
[ ] Ajouter tests automatises (pytest)
[ ] Implementer rate limiting
[ ] Refactoriser web_interface.py
MOYEN TERME (1-2 mois) :
[ ] Migrer vers FastAPI
[ ] Ajouter cache Redis
[ ] Implementer CI/CD
[ ] Migrer vers PostgreSQL
LONG TERME (3-6 mois) :
[ ] Ajouter questionnaires GAD-7, AUDIT-C
[ ] Support multi-langues
[ ] Application mobile (PWA)
[ ] Dashboard analytics
================================================================================
ANNEXES
================================================================================
A. DEPENDANCES (requirements.txt)
---------------------------------
auralis>=0.2.8.post2
werkzeug>=3.1.3
flask>=3.1.1
openai-whisper>=20240930
chromadb>=1.0.12
sentence_transformers>=4.1.0
pyOpenSSL>=25.1.0
accelerate>=1.8.1
bitsandbytes>=0.46.0
pymupdf>=1.26.3
B. CONTACTS ET SUPPORT
----------------------
Developpeurs : Damien Micottis et Richard Loyer
Institution : Master UTBM 2024/2025
C. LICENCE
----------
[A definir par les auteurs]
================================================================================
FIN DU DOCUMENT
================================================================================

303
GUIDE_ROOT_ONEPLUS_PAD3.md Normal file
View File

@ -0,0 +1,303 @@
# Guide Root - OnePlus Pad 3 (OPD2415) pour le projet Kazeia
## Pourquoi rooter ?
Le Snapdragon 8 Elite embarque un NPU (Neural Processing Unit) capable d'exécuter les modèles d'IA 10 à 100x plus vite que le CPU. Sans root, Android bloque l'accès aux librairies vendor Qualcomm (libcdsprpc.so, libQnnHtp.so) nécessaires pour communiquer avec le NPU depuis une application tierce.
### Impact sur les performances
| Composant | Sans root (CPU) | Avec root (NPU) | Gain |
|-----------|-----------------|------------------|------|
| **STT Whisper-Base** | ~1500ms | ~50ms | x30 |
| **LLM Qwen3-4B (Genie SDK)** | Indisponible (erreur -5) | ~30 tok/s | ∞ |
| **TTS Chatterbox** | OOM crash (1.4 GB RAM) | ~1-3s sur NPU | Fonctionne |
| **Monitoring GPU/NPU** | 0% (sysfs inaccessible) | Métriques temps réel | Visibilité |
| **Pipeline complet (STT→LLM→TTS)** | Impossible | ~4-8s end-to-end | Pipeline complet |
### Composants bloqués sans root
- **Genie SDK** : le `GenieDialogConfig_createFromJson()` retourne le code d'erreur -7 car il ne peut pas ouvrir les devices DSP
- **QNN HTP** : `libcdsprpc.so` nécessite `libhidlbase.so` du namespace vendor, inaccessible aux apps normales
- **ONNX Runtime QNN EP** : le QNN Execution Provider s'initialise mais ne peut pas communiquer avec le HTP
- **Sysfs monitoring** : `/sys/class/kgsl/` (GPU) et `/sys/class/devfreq/` (NPU) sont protégés par SELinux
### Ce qui fonctionne sans root
- Whisper.cpp sur CPU (STT)
- Android TTS natif (Google)
- ONNX Runtime sur CPU
- Silero VAD (ONNX CPU)
- LiteRT avec NNAPI (délégation partielle, limitée)
---
## Prérequis
- **Tablette** : OnePlus Pad 3 (OPD2415)
- **Firmware** : OPD2415_16.0.3.500(EX01) — OxygenOS 16 / Android 16 (API 36)
- **SoC** : Snapdragon 8 Elite (SM8750, plateforme "sun")
- **Slot actif** : A
- **Bootloader** : verrouillé (à déverrouiller)
- **PC** : avec ADB et Fastboot installés
- **Sauvegarde** : aucune donnée critique sur la tablette (le déverrouillage efface tout)
---
## Étape 1 — Activer le déverrouillage OEM
Sur la tablette :
1. **Paramètres → À propos de la tablette**
2. Taper **7 fois** sur "Numéro de build" pour activer les Options développeur
3. **Paramètres → Système → Options développeur**
4. Activer **Déverrouillage OEM**
5. Vérifier que **Débogage USB** est activé
Vérification depuis le PC :
```bash
adb shell getprop sys.oem_unlock_allowed
# Doit retourner "1"
```
---
## Étape 2 — Déverrouiller le bootloader
⚠️ **ATTENTION : Cette étape efface TOUTES les données de la tablette.**
```bash
# Redémarrer en mode fastboot
adb reboot bootloader
# Attendre que la tablette soit en mode fastboot (écran avec logo)
# Vérifier la connexion
fastboot devices
# Déverrouiller le bootloader
fastboot flashing unlock
```
Sur la tablette :
- Utiliser les **boutons volume** pour sélectionner "Unlock the bootloader"
- Confirmer avec le **bouton power**
- La tablette va se réinitialiser et redémarrer
```bash
# Vérification
adb shell getprop ro.boot.vbmeta.device_state
# Doit retourner "unlocked"
```
Après le redémarrage :
- Reconfigurer la tablette (setup minimal)
- Réactiver les Options développeur + Débogage USB
---
## Étape 3 — Récupérer le firmware stock
Il faut le fichier `init_boot.img` de la **même version** de firmware installée.
### Option A : Extraire depuis un OTA complet
1. Télécharger le firmware OPD2415_16.0.3.500 depuis :
- [OnePlus Firmware Archive](https://www.oneplus.com/support/softwareupgrade)
- Ou communautés XDA/OnePlus
2. Extraire le ZIP
3. Trouver `init_boot.img` dans le payload :
```bash
# Si le firmware est un payload.bin
python3 payload_dumper.py payload.bin --out extracted/
# init_boot.img sera dans extracted/
```
### Option B : Extraire depuis la partition active
```bash
# Identifier la partition init_boot
adb shell ls -la /dev/block/by-name/init_boot*
# Extraire (nécessite un accès shell suffisant)
adb shell dd if=/dev/block/by-name/init_boot_a of=/data/local/tmp/init_boot.img
adb pull /data/local/tmp/init_boot.img
```
---
## Étape 4 — Patcher avec Magisk
1. **Télécharger Magisk** : [github.com/topjohnwu/Magisk/releases](https://github.com/topjohnwu/Magisk/releases)
- Prendre le fichier `Magisk-v28.x.apk` (dernière version stable)
2. **Installer Magisk sur la tablette** :
```bash
adb install Magisk-v28.x.apk
```
3. **Transférer init_boot.img sur la tablette** :
```bash
adb push init_boot.img /data/local/tmp/
```
4. **Patcher avec Magisk** :
- Ouvrir l'app Magisk sur la tablette
- Appuyer sur **"Installer"** à côté de "Magisk"
- Choisir **"Sélectionner et patcher un fichier"**
- Naviguer vers `/data/local/tmp/init_boot.img`
- Attendre la fin du patching
- Le fichier patché sera dans `/storage/emulated/0/Download/magisk_patched-xxxxx.img`
5. **Récupérer l'image patchée** :
```bash
adb pull /storage/emulated/0/Download/magisk_patched-28100_xxxxx.img
```
---
## Étape 5 — Flasher l'image patchée
```bash
# Redémarrer en fastboot
adb reboot bootloader
# Flasher l'image patchée sur la partition init_boot
fastboot flash init_boot magisk_patched-28100_xxxxx.img
# Redémarrer
fastboot reboot
```
---
## Étape 6 — Vérification
Après le redémarrage :
```bash
# Vérifier que Magisk est actif
adb shell su -c id
# Doit retourner: uid=0(root) gid=0(root)
# Vérifier l'accès aux libs vendor
adb shell su -c "ls /vendor/lib64/libcdsprpc.so"
adb shell su -c "ls /vendor/lib64/libQnnHtp.so"
# Vérifier l'accès au DSP
adb shell su -c "ls /dev/adsprpc-smd"
# Vérifier les sysfs GPU
adb shell su -c "cat /sys/class/kgsl/kgsl-3d0/gpubusy"
```
---
## Étape 7 — Configuration post-root pour Kazeia
### Permissions DSP pour l'app
```bash
# Donner accès au DSP à l'app Kazeia
adb shell su -c "chmod 666 /dev/adsprpc-smd"
adb shell su -c "chmod 666 /dev/cdsprpc-smd"
# Ou via Magisk module pour persister après reboot
```
### Module Magisk recommandé
Créer un module Magisk qui :
- Rend les libs QNN accessibles aux apps
- Ouvre les permissions DSP au boot
- Expose les sysfs GPU/NPU
### Variables d'environnement
L'app devra setter ces variables avant de charger les libs QNN :
```
ADSP_LIBRARY_PATH=/vendor/lib64/
LD_LIBRARY_PATH=/vendor/lib64/
```
---
## Risques et précautions
### Ce que le root implique
- **Garantie** : techniquement annulée (mais reversible en re-verrouillant le bootloader)
- **Mises à jour OTA** : ne fonctionneront plus automatiquement (flash manuel nécessaire)
- **SafetyNet / Play Integrity** : certaines apps (banque, Netflix) peuvent refuser de fonctionner
- Solution : module Magisk "Universal SafetyNet Fix" ou "Play Integrity Fix"
- **Sécurité** : le root expose le système — ne pas installer d'apps douteuses
### Comment revenir en arrière
```bash
# Re-verrouiller le bootloader (efface tout)
adb reboot bootloader
fastboot flashing lock
# Confirmer sur la tablette
fastboot reboot
```
### Sauvegarde avant root
```bash
# Sauvegarder les modèles déjà poussés
adb pull /data/local/tmp/kazeia/ ./backup_kazeia/
```
---
## Résumé des commandes
```bash
# 1. Déverrouiller
adb reboot bootloader
fastboot flashing unlock
# Confirmer sur tablette
# 2. Après reconfiguration tablette
adb install Magisk-v28.x.apk
adb push init_boot.img /data/local/tmp/
# 3. Patcher sur tablette via app Magisk, puis récupérer
adb pull /storage/emulated/0/Download/magisk_patched-*.img
# 4. Flasher
adb reboot bootloader
fastboot flash init_boot magisk_patched-*.img
fastboot reboot
# 5. Vérifier
adb shell su -c id
```
---
## Après le root — Impact sur Kazeia
Une fois rooté, modifier `KazeiaApplication.kt` pour ajouter les chemins vendor :
```kotlin
companion object {
const val MODELS_DIR = "/data/local/tmp/kazeia/models"
init {
// Avec root, les libs vendor sont accessibles
System.setProperty("ADSP_LIBRARY_PATH", "/vendor/lib64/")
}
}
```
Les composants suivants deviendront fonctionnels :
- ✅ Genie SDK → LLM Qwen3-4B sur NPU
- ✅ ONNX Runtime QNN EP → Whisper encoder/decoder sur NPU
- ✅ Monitoring GPU/NPU via sysfs
- ✅ Chatterbox TTS sur NPU (réduction mémoire)
---
*Document créé le 28 mars 2026 — Projet Kazeia*
*Damien Micottis & Richard Loyer*

6364
KAZEIA-CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

253
RAPPORT_TTS.md Normal file
View File

@ -0,0 +1,253 @@
# Rapport TTS - Qwen3-TTS sur OnePlus Pad 3
**Date** : 26 mars 2026
**Objectif** : Synthèse vocale avec clonage de voix en français pour le chatbot Kazeia
**Tablette** : OnePlus Pad 3 (Snapdragon 8 Elite, NPU Hexagon HTP v79)
---
## 1. Modèles TTS évalués
### 1.1 Chatterbox Multilingual (ONNX)
| Caractéristique | Détail |
|---|---|
| Repo | `onnx-community/chatterbox-multilingual-ONNX` |
| Architecture | LLM 0.5B (Llama) + speech encoder + conditional decoder |
| Français | Oui (23 langues) |
| Clonage vocal | Oui |
| Format | ONNX (FP32, FP16, Q4F16) |
| Taille totale | ~1.5 Go (Q4F16) |
**Résultat** : Le modèle charge et génère des tokens mais le **décodage audio ne fonctionne que sur la première seconde**. Les versions quantifiées (FP16, Q4F16) ne produisent pas le stop token, résultant en du silence après le début. Seul le FP32 (2 Go LM) fonctionne correctement mais uniquement pour des phrases très courtes.
**Verdict** : Non fiable pour la production.
### 1.2 Qwen3-TTS (PyTorch natif)
| Caractéristique | 0.6B Base | 1.7B CustomVoice | 1.7B Base |
|---|---|---|---|
| Clonage vocal | Oui (x_vector) | Non (voix intégrées) | Oui (x_vector) |
| Français | Oui | Oui | Oui |
| Taille FP32 | ~1.8 Go | ~4.5 Go | ~4.5 Go |
| Qualité clonage | Bonne | N/A | Bonne |
**Résultat** : Fonctionne parfaitement sur CPU avec les voix françaises du projet Kazeia.
---
## 2. Tests Qwen3-TTS sur CPU (PC)
**Phrase** : *"Bonjour, je comprends que vous soyez triste. Je suis là pour vous écouter."*
### 2.1 Résultats par modèle et voix
| Modèle | Voix | Audio | Temps | Vitesse | Qualité |
|---|---|---:|---:|---|---|
| **0.6B Base** | Damien (clone) | 4.0s | 18.4s | 0.22x RT | Bonne |
| **0.6B Base** | Élodie (clone) | 4.3s | 18.6s | 0.23x RT | Bonne |
| **1.7B CustomVoice** | Vivian (intégrée) | 6.1s | 37.8s | 0.16x RT | Très bonne |
| **1.7B CustomVoice** | Serena (intégrée) | 4.3s | 26.4s | 0.16x RT | Très bonne |
| **1.7B Base** | Damien (clone) | 5.0s | 30.9s | 0.16x RT | Très bonne |
| **1.7B Base** | Élodie (clone) | 3.8s | 23.7s | 0.16x RT | Très bonne |
### 2.2 Observations
- Le **0.6B est ~1.4x plus rapide** que le 1.7B (0.22x vs 0.16x RT)
- Le **clonage vocal** fonctionne bien avec les voix françaises (accent naturel)
- Les échantillons de référence doivent être **courts** (~3-10 secondes)
- L'audio est continu et complet sur toute la durée (RMS > 0 partout)
- Le 0.6B offre le **meilleur rapport qualité/vitesse** pour le clonage
---
## 3. Export du talker Qwen3-TTS pour le NPU
### 3.1 Architecture du modèle
Qwen3-TTS 0.6B est composé de 4 sous-modèles :
```
Texte + Voix ref → [Speaker Encoder] → x-vector
[Talker LM] ← 28 couches Qwen3, 1024 hidden
↓ (identique à Qwen3-0.6B LLM)
[Code Predictor] ← 5 couches, 15 codebooks
[Speech Decoder] ← ConvNet + Transformer
Audio WAV
```
| Composant | Params | Rôle |
|---|---:|---|
| **Talker (LM)** | 754.8M | Génère les speech tokens (autorégressif) |
| Code Predictor | 141.6M | Prédit les 15 codebooks parallèles |
| Speaker Encoder | 8.9M | Extrait l'empreinte vocale |
| Speech Decoder | 114.3M | Décode les tokens en audio |
### 3.2 Conversion pour ExecuTorch + QNN
Le talker est **architecturalement identique** à Qwen3-0.6B :
- 28 couches Transformer
- hidden_size = 1024
- 16 attention heads, 8 KV heads
- head_dim = 128
- QK norm + RoPE theta 1M
- Seule différence : vocab_size = 3072 (codec) au lieu de 151936 (texte)
**Étapes de conversion** :
1. Extraction des poids du talker depuis le modèle HuggingFace
2. Renommage HF → Meta format (wq/wk/wv/wo, feed_forward.w1/w2/w3)
3. Remplacement de `tok_embeddings` par `codec_embedding` (3072x1024)
4. Suppression de `text_projection` et `text_embedding`
5. Patch du tokenizer pour clamper les IDs de calibration à [0, 3071]
6. Export via le pipeline ExecuTorch Qwen3-0.6B existant
**Résultat** : `.pte` de **286 Mo** généré en ~20 minutes.
### 3.3 Test sur le NPU Hexagon
| Métrique | Valeur |
|---|---|
| **Débit decode** | **90.7 tok/s** |
| Prefill | 888 tok/s |
| Tokens générés | 503 en 5.5s |
| Taille .pte | 286 Mo |
| Time to first token | 9 ms |
**Comparaison** :
| Backend | Talker decode (tok/s) | vs CPU |
|---|---:|---|
| **NPU Hexagon (ExecuTorch)** | **90.7** | **+4.1x** |
| CPU PC (PyTorch) | ~22 | baseline |
### 3.4 Exportabilité des autres composants
| Composant | ExecuTorch | ONNX | Raison échec |
|---|---|---|---|
| **Talker** | **OK** (286 Mo .pte) | Échoue | torch.export incompatible |
| Code Predictor | Non | Non | Multi-tête (15 codebooks), non standard |
| Speaker Encoder | Non tenté | Non tenté | Petit (8.9M), rapide sur CPU |
| Speech Decoder | Non tenté | Échoue | ConvNet dynamique + boucles |
---
## 4. Pipeline TTS recommandé pour la tablette
### 4.1 Architecture hybride NPU + CPU
```
┌─────────────────────────────────────────────┐
│ TABLETTE │
│ │
│ [Speaker Encoder] ──→ x-vector │
│ (CPU, 8.9M) │
│ │
│ [Talker .pte] ──→ speech tokens │
│ (NPU, 90 tok/s, 286 Mo) │
│ │
│ [Code Predictor] ──→ 16 codebooks │
│ (CPU, 141M, ~5 couches) │
│ │
│ [Speech Decoder] ──→ audio WAV │
│ (CPU, 114M, ConvNet) │
└─────────────────────────────────────────────┘
```
### 4.2 Mesure réelle du pipeline (CPU PC) et estimation NPU
**Phrase** : *"Bonjour, je suis là pour vous accompagner."* → 2.3s d'audio
| Composant | % temps | CPU PC | NPU tablette (estimé) |
|---|---:|---:|---:|
| **Talker (LM autorégressif)** | **87%** | 9.9s | **0.3s** (90.7 tok/s) |
| Speaker Encoder + Code Predictor + Decoder | 13% | 1.5s | ~1.5s |
| **Total** | 100% | **11.5s** | **~1.9s** |
| **Ratio temps réel** | | 0.20x (5x trop lent) | **1.24x RT** |
Le NPU transforme un pipeline **5x trop lent** en un pipeline **temps réel** en accélérant le talker (le goulot d'étranglement) de 3 tok/s à 90.7 tok/s.
### 4.3 Prérequis pour le déploiement complet
1. **Termux** sur la tablette avec Python 3.10 + PyTorch CPU
2. Le `.pte` du talker déjà déployé
3. Les poids du code predictor + speech decoder en PyTorch
4. Un script d'orchestration qui :
- Encode la voix de référence (CPU)
- Génère les speech tokens via le talker .pte (NPU)
- Prédit les codebooks (CPU)
- Décode en audio (CPU)
---
## 5. Fichiers produits
### Sur le PC
| Chemin | Description |
|---|---|
| `models_qnn/qwen3-tts-executorch/hybrid_llama_qnn.pte` | Talker exporté pour NPU (286 Mo) |
| `models_qnn/qwen3-tts-export/qwen3_tts_talker.pth` | Poids talker format Meta (3.1 Go) |
| `models_qnn/qwen3-tts-export/config.json` | Config ExecuTorch du talker |
| `models_qnn/chatterbox-tts/` | Chatterbox ONNX (abandonné) |
| `tts_qwen3/` | Échantillons audio générés |
| `voix_clips/` | Voix françaises tronquées à 5s |
### Échantillons audio générés
| Fichier | Modèle | Voix |
|---|---|---|
| `tts_qwen3/qwen3tts_06b_damien.wav` | 0.6B Base | Damien (clone) |
| `tts_qwen3/qwen3tts_06b_elodie.wav` | 0.6B Base | Élodie (clone) |
| `tts_qwen3/qwen3tts_17b_Vivian.wav` | 1.7B CustomVoice | Vivian |
| `tts_qwen3/qwen3tts_17b_Serena.wav` | 1.7B CustomVoice | Serena |
| `tts_qwen3/qwen3tts_17b_base_damien.wav` | 1.7B Base | Damien (clone) |
| `tts_qwen3/qwen3tts_17b_base_elodie.wav` | 1.7B Base | Élodie (clone) |
### Voix de référence françaises
8 voix disponibles dans `/opt/Kazeia/voix/` (clips 5s dans `voix_clips/`) :
Amir, Damien, Didier, Élodie, Jérôme, Richard, Sid, Zelda
---
## 6. Commandes de référence
### Générer de la parole avec Qwen3-TTS (CPU PC)
```python
from qwen_tts import Qwen3TTSModel
model = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-0.6B-Base", device_map="cpu", dtype=torch.float32)
wavs, sr = model.generate_voice_clone(
text="Votre texte ici",
language="French",
ref_audio="voix_clips/damien.wav",
ref_text="Bonjour",
x_vector_only_mode=True,
)
soundfile.write("output.wav", wavs[0], sr)
```
### Exporter le talker TTS pour le NPU
```bash
# 1. Convertir les poids (voir script de conversion)
# 2. Exporter via ExecuTorch (avec patch tokenizer)
python3.10 examples/qualcomm/oss_scripts/llama/llama.py \
-m SM8750 -b build-android --decoder_model qwen3-0_6b \
--checkpoint qwen3_tts_talker.pth --params config.json \
-s DEVICE_ID --backend htp -c -a OUTPUT_DIR
```
### Tester le talker sur le NPU tablette
```bash
python3.10 examples/qualcomm/oss_scripts/llama/llama.py \
-m SM8750 -b build-android --decoder_model qwen3-0_6b \
-s DEVICE_ID --backend htp \
--pre_gen_pte OUTPUT_DIR -a OUTPUT_DIR --prompt "test"
```
---
*Rapport généré par Claude Code (Opus 4.6)*

151
TTS_CALIBRATION_GUIDE.md Normal file
View File

@ -0,0 +1,151 @@
# Guide de calibration TTS Qwen3-TTS pour NPU
## ExecuTorch + QNN quantification calibrée
---
## 1. Pourquoi la calibration
Le fp16 brut (sans calibration) échoue sur les modèles TTS :
- **CP fp16** : produit du bruit (codebooks complètement faux)
- **Talker fp16** : produit du silence (tokens dans un mauvais régime)
- **Cause** : l'autoregression amplifie les erreurs de précision
La calibration observe les plages d'activation réelles du modèle et ajuste la quantification pour minimiser la distorsion. C'est la technique utilisée pour le LLM Qwen3-0.6B (93 tok/s sur NPU).
---
## 2. Phrases de calibration
**Fichier** : `models_qnn/calibration_phrases.json`
10 langues × 5 phrases = 50 phrases couvrant :
- Chinois, Anglais, Allemand, Espagnol, Japonais
- Français, Coréen, Russe, Italien, Portugais
Chaque phrase couvre des phonèmes variés, prosodie (questions, exclamations), et cas difficiles.
---
## 3. Collecte des tenseurs de calibration
### Prérequis
- Python 3.10 dans `/opt/Kazeia/qnn_venv/`
- Modèle Qwen3-TTS dans le cache HuggingFace
- Speaker embedding Damien dans `models_qnn/qwen3-tts-embeddings/`
### Commande
```bash
/opt/Kazeia/qnn_venv/bin/python3 /opt/Kazeia/models_qnn/collect_calibration.py
```
### Script : `models_qnn/collect_calibration.py`
Le script :
1. Charge le modèle Qwen3-TTS complet en fp32
2. Hook le `talker.model.forward` et `code_predictor.model.forward`
3. Pour chaque phrase, lance `model.generate()` avec le pipeline complet (sampling, tts_pad, voice cloning)
4. Sauvegarde les `inputs_embeds` de chaque forward pass
### Sortie
```
models_qnn/calibration_data/
├── talker_inputs/ # ~2500 tenseurs .pt ([1, 1, 1024])
│ ├── french_0_step0.pt
│ ├── french_0_step1.pt
│ └── ...
└── cp_inputs/ # ~37000 tenseurs .pt ([1, 2..17, 1024])
├── french_0_call0.pt
└── ...
```
### Durée : ~30-60 minutes sur CPU
---
## 4. Export avec calibration (étape suivante)
### Pipeline ExecuTorch
```python
from torch.ao.quantization.quantize_pt2e import prepare_pt2e, convert_pt2e
from executorch.backends.qualcomm.quantizer import QnnQuantizer, QuantDtype
# 1. Créer le wrapper (même que pour l'export fp16)
wrapper = TalkerKVWrapper(model.talker.model, model.talker.codec_head)
# 2. torch.export
exported = torch.export.export(wrapper, example_inputs, strict=False)
# 3. Préparer la quantification
quantizer = QnnQuantizer()
quantizer.set_quant_config(QuantDtype.use_16a8w) # 16-bit activations, 8-bit weights
prepared = prepare_pt2e(exported, quantizer)
# 4. Calibration : rejouer les tenseurs collectés
for tensor_file in calibration_files:
inputs = torch.load(tensor_file)
prepared(*rebuild_full_inputs(inputs))
# 5. Convertir
quantized = convert_pt2e(prepared)
# 6. Export QNN
edge = to_edge_transform_and_lower_to_qnn(quantized, ...)
pte = edge.to_executorch()
```
### Schémas de quantification à tester
| Schéma | Poids | Activations | KV cache | Taille estimée |
|--------|-------|-------------|----------|----------------|
| use_16a8w | 8-bit | 16-bit | 16-bit | ~900 MB |
| use_16a4w | 4-bit | 16-bit | 16-bit | ~500 MB |
| use_8a8w | 8-bit | 8-bit | 8-bit | ~450 MB |
**Recommandation** : commencer par `use_16a8w` (le plus conservateur), puis tester `use_16a4w` si la qualité est bonne.
---
## 5. Fichiers clés
| Fichier | Description |
|---------|-------------|
| `models_qnn/calibration_phrases.json` | 50 phrases en 10 langues |
| `models_qnn/collect_calibration.py` | Script de collecte |
| `models_qnn/calibration_data/` | Tenseurs de calibration |
| `models_qnn/qwen3-tts-onnx/talker_rotary_cos.npy` | M-RoPE pré-calculé talker |
| `models_qnn/qwen3-tts-onnx/talker_rotary_sin.npy` | M-RoPE pré-calculé talker |
| `models_qnn/qwen3-tts-onnx/cp_rotary_cos.npy` | RoPE pré-calculé CP |
| `models_qnn/qwen3-tts-onnx/cp_rotary_sin.npy` | RoPE pré-calculé CP |
---
## 6. Wrappers PyTorch validés
### Talker wrapper (28 layers, M-RoPE)
- 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)]`
- Validé identique à PyTorch (diff logits < 0.00006)
- M-RoPE pré-calculé avec `apply_interleaved_rope` pour les 3 axes (identiques en TTS)
### CP wrapper (5 layers, RoPE standard)
- Inputs : `[emb(1,1,1024), mask(1,1,1,17), cos(1,1,128), sin(1,1,128), 10×kv(1,8,16,128)]`
- Outputs : `[hidden(1,1,1024), head_logits(1,15,2048), 10×kv(1,8,17,128)]`
- Validé 15/15 match vs PyTorch
- Inclut projection + 15 heads dans le même modèle
### Points critiques (bugs trouvés et corrigés)
1. **q_norm / k_norm** : RMSNorm sur Q et K avant rotary — obligatoire
2. **M-RoPE interleaved** : le talker utilise un rotary multimodal à 3 axes
3. **WrapWithSetGradEnabled** : contourné en pré-calculant cos/sin
4. **tts_pad après texte** : le modèle attend tts_pad_embed (pas des zeros) après l'EOS texte
---
## 7. Résultats NPU sans calibration (pour référence)
| Config | Vitesse | Qualité | EOS |
|--------|---------|---------|-----|
| Talker fp16 .pte | 67ms/step | Silence | Oui (step 60) |
| CP fp16 .pte | 55ms/17 steps | Bruit | Non (degeneration) |
| Talker ONNX QNN int8 | ~20ms/step | EOS prématuré | 1.4-2.2s |
| CP ONNX QNN int8 | ~4ms/step | Pause/bruit | Non |
La calibration vise à obtenir la vitesse NPU avec la qualité CPU.

154
TTS_GPU_GUIDE.md Normal file
View File

@ -0,0 +1,154 @@
# Guide GPU Adreno pour TTS Qwen3-TTS
## ONNX Runtime QNN GPU Backend — Audio parfait, tokens identiques au CPU
---
## 1. Résumé
Le GPU Adreno 830 produit un audio TTS **parfait** via ONNX Runtime QNN avec le backend GPU (`libQnnGpu.so`). Contrairement au NPU (HTP) qui quantifie et détruit la qualité, le GPU fait du **fp32/fp16 natif IEEE-754** sans quantification.
**Résultat :** tokens identiques au CPU (1995, 215, 212...), EOS naturel, audio impeccable.
**Vitesse :** 124-131ms/step (identique au CPU — pas de gain de vitesse dû à l'overhead de transfert mémoire par token).
---
## 2. Changement de code (1 ligne)
### Dans `Qwen3TtsEngine.kt`, charger le talker avec le GPU backend :
```kotlin
// AVANT (CPU)
val talkerOpts = OrtSession.SessionOptions()
talkerKv = ortEnv!!.createSession(cpuOnnx.absolutePath, talkerOpts)
talkerUsesInt64Pos = true
// APRÈS (GPU Adreno)
val gpuPath = "$nativeLibDir/libQnnGpu.so"
val opts = OrtSession.SessionOptions()
opts.addQnn(mapOf("backend_path" to gpuPath))
talkerKv = ortEnv!!.createSession(cpuOnnx.absolutePath, opts)
talkerUsesInt64Pos = false // CRITIQUE: GPU QNN exige int32, pas int64
```
### Point critique : `talkerUsesInt64Pos = false`
Le GPU QNN backend n'accepte pas les tenseurs `int64` pour `position_ids`. L'erreur sinon :
```
ORT_INVALID_ARGUMENT: Unexpected input data type. Actual: (tensor(int64)), expected: (tensor(int32))
```
Le CPU ONNX accepte `int64`, le HTP aussi, mais le GPU non. Il faut envoyer `int32`.
### Code de création du tenseur position (dans `runTalkerStep`) :
```kotlin
val posTensor = if (talkerUsesInt64Pos) {
OnnxTensor.createTensor(env, LongBuffer.wrap(longArrayOf(pos.toLong())), longArrayOf(1))
} else {
OnnxTensor.createTensor(env, IntBuffer.wrap(intArrayOf(pos)), longArrayOf(1))
}
```
---
## 3. Bibliothèques nécessaires
### Dans `app/src/main/jniLibs/arm64-v8a/` :
| Fichier | Source | Taille |
|---------|--------|--------|
| `libQnnGpu.so` | QNN SDK `lib/aarch64-android/` | 6.1 MB |
| `libQnnGpuNetRunExtensions.so` | QNN SDK `lib/aarch64-android/` | ~1 MB |
| `libQnnGpuProfilingReader.so` | QNN SDK `lib/aarch64-android/` | ~0.5 MB |
### Commande pour copier depuis le QNN SDK :
```bash
QNN_SDK=/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225
cp $QNN_SDK/lib/aarch64-android/libQnnGpu*.so \
kazeia-android/app/src/main/jniLibs/arm64-v8a/
```
### Dépendances système (déjà présentes sur Android) :
- `libEGL.so` — OpenGL ES context
- `libGLESv2.so` — OpenGL ES 2.0
- `libOpenCL.so` — déjà dans `/vendor/etc/public.libraries.txt` sur OnePlus Pad 2
---
## 4. Modèle ONNX
**Aucune re-exportation nécessaire.** Le même modèle ONNX CPU fonctionne sur GPU :
- `talker_kv_cpu/model.onnx` (1.77 GB) — utilisé tel quel
- Le GPU backend d'ONNX Runtime QNN compile le graph ONNX à la volée
### Pas de cache GPU
Le context caching (`qnn_context_cache_enable`) ne fonctionne PAS avec le backend GPU d'ONNX Runtime (contrairement au HTP). Chaque session recompile le graph (~2.5s de chargement).
---
## 5. Pourquoi le GPU fonctionne mais pas le NPU
| Aspect | NPU (HTP) | GPU (Adreno) |
|--------|-----------|--------------|
| Précision | INT8/INT16 quantifié | FP16/FP32 natif IEEE-754 |
| Quantification | Automatique, destructive | Aucune |
| Codebook argmax | Changé par la quantification | Identique au CPU |
| Audio TTS | Bruit / silence / inintelligible | **Parfait** |
| Vitesse | ~20ms/step (inutilisable) | ~130ms/step |
Le TTS sélectionne des codebooks par `argmax` sur 2048 valeurs. La moindre erreur de quantification (NPU) change le codebook sélectionné et cascade dans l'autoregression. Le GPU fait du vrai fp32 → mêmes codebooks → même audio.
---
## 6. Performance
| Métrique | CPU fp32 | GPU fp16/fp32 |
|----------|---------|---------------|
| Chargement | 2.5s | 2.8s (+graph compile) |
| Talker/step | 130ms | 124-131ms |
| Audio | Parfait | Parfait |
| RTF | 7.0 | 7.0 |
**Pas de gain de vitesse.** Le GPU est memory-bound pour les petits batch sizes (1 token). L'overhead de transfert CPU→GPU→CPU par step annule le gain de calcul parallèle du GPU.
**Utilité :** le GPU libère les cœurs CPU pour d'autres tâches (CP, UI, audio playback). En mode streaming, le talker sur GPU + CP sur CPU fonctionneraient en parallèle.
---
## 7. Dépendances Gradle
```kotlin
// Déjà présent pour le reste du pipeline TTS
implementation("com.microsoft.onnxruntime:onnxruntime-android-qnn:1.24.3")
```
Pas de dépendance supplémentaire. Le QNN GPU backend est inclus dans l'AAR ONNX Runtime.
---
## 8. Erreurs courantes
### `Unexpected input data type. Actual: (tensor(int64)), expected: (tensor(int32))`
→ Mettre `talkerUsesInt64Pos = false` pour envoyer `position_ids` en `int32`.
### `Cannot Open QNN library libQnnGpu.so`
→ Copier `libQnnGpu.so` dans `jniLibs/arm64-v8a/`.
### `Execution failed for method: forward` (ExecuTorch)
→ Le JNI ExecuTorch ne fonctionne PAS avec le GPU QNN depuis l'app (problème de contexte GPU). Utiliser ONNX Runtime à la place.
### Pas de cache GPU
→ Normal. Le QNN GPU backend ne supporte pas le context caching. Le graph est recompilé à chaque session (~2.5s).
---
## 9. Architecture finale recommandée
```
LLM Qwen3-0.6B → NPU HTP (93 tok/s, INT4 calibré) — ExecuTorch
Whisper STT → NPU HTP (INT8 calibré) — ONNX Runtime QNN
TTS Talker → GPU Adreno (fp32 natif) — ONNX Runtime QNN
TTS CP → CPU fp32 — ONNX Runtime
TTS Decoder → NPU HTP — ONNX Runtime QNN
Silero VAD → CPU (1.8 Mo) — ONNX Runtime
```
Chaque composant sur le backend optimal : NPU pour ce qui tolère la quantification, GPU pour le TTS qui exige la précision, CPU pour le reste.

128
TTS_HEXAGON_NPU_GUIDE.md Normal file
View File

@ -0,0 +1,128 @@
# Guide Hexagon NPU FP16 natif pour TTS
## llama.cpp + ggml-hexagon + HMX FP16 — 47.8 tok/s
---
## 1. Pourquoi ça marche
Le QNN SDK quantifie automatiquement en int8/int16 → détruit le TTS.
Le ggml-hexagon contourne le QNN SDK et accède directement aux unités HMX
du Hexagon DSP en **vrai FP16 IEEE-754** via des kernels reverse-engineerés
(htp-ops-lib, Zixu Hao, EuroSys 2026).
| Approche | Précision | Vitesse | Audio TTS |
|----------|-----------|---------|-----------|
| QNN SDK HTP | int8/int16 quantifié | ~11ms/step | Silence/bruit |
| QNN SDK GPU | fp16 IEEE-754 | ~130ms/step | Parfait |
| **ggml-hexagon HMX** | **fp16 IEEE-754** | **~21ms/step** | **À tester** |
| ONNX Runtime CPU | fp32 | ~107ms/step | Parfait |
## 2. Build (snapdragon toolchain Docker)
### Prérequis
- Podman ou Docker
- Source llama.cpp
### Commandes
```bash
cd /opt/Kazeia/llama.cpp
cp docs/backend/snapdragon/CMakeUserPresets.json .
mkdir -p build-snapdragon
# Configure
podman run --rm --userns=keep-id \
--volume $(pwd):/workspace:Z \
--platform linux/amd64 \
ghcr.io/snapdragon-toolchain/arm64-android:v0.3 \
bash -c "cd /workspace && cmake --preset arm64-android-snapdragon-release -B build-snapdragon"
# Build
podman run --rm --userns=keep-id \
--volume $(pwd):/workspace:Z \
--platform linux/amd64 \
ghcr.io/snapdragon-toolchain/arm64-android:v0.3 \
bash -c "cd /workspace && cmake --build build-snapdragon -j\$(nproc)"
```
### Outputs
```
build-snapdragon/bin/llama-cli # CLI (ARM64)
build-snapdragon/bin/lib*.so # Shared libs
build-snapdragon/ggml/src/ggml-hexagon/libggml-htp-v79.so # Hexagon v79 skel (HMX FP16)
```
## 3. Conversion talker → GGUF F16
Le talker TTS est un Qwen3 standard (28 layers, 1024 dim, q_norm/k_norm).
On extrait ses poids et crée un GGUF compatible.
### Script : extraction des poids
```python
# Extraire les poids du talker dans un format HF standalone
state_dict = {}
for name, param in inner.named_parameters():
if name not in skip_set:
state_dict[f"model.{name}"] = param.detach().clone()
state_dict["model.embed_tokens.weight"] = inner.codec_embedding.weight.detach().clone()
state_dict["lm_head.weight"] = codec_head.weight.detach().clone()
```
### Script : création GGUF manuelle
```python
from gguf import GGUFWriter, GGMLQuantizationType
writer = GGUFWriter("talker_f16.gguf", "qwen3")
# ... add metadata (hidden_size, num_layers, etc.)
# ... add tensors with F16 weights, F32 norms
writer.add_tokenizer_model("none") # pas de tokenizer texte
```
### Fichier : `models_qnn/talker_f16.gguf` (852 MB)
## 4. Déploiement sur tablette
```bash
# Push binaries
DST=/data/local/tmp/kazeia/llama-hex
adb shell "mkdir -p $DST"
adb push build-snapdragon/bin/llama-* $DST/
adb push build-snapdragon/bin/*.so $DST/
adb push build-snapdragon/ggml/src/ggml-hexagon/libggml-htp-v*.so $DST/
adb push models_qnn/talker_f16.gguf /data/local/tmp/kazeia/models/
# Benchmark
adb shell "cd $DST && LD_LIBRARY_PATH=. ./llama-bench \
-m /data/local/tmp/kazeia/models/talker_f16.gguf \
-mmp 0 -ngl 99 -pg 1,5"
```
## 5. Benchmark (SM8750, Hexagon v79)
```
| model | size | backend | test | tok/s |
|-----------------|----------|-------------|----------|----------|
| qwen3 0.6B F16 | 852 MiB | OpenCL,HTP | pp512 | 464 ± 16 |
| qwen3 0.6B F16 | 852 MiB | OpenCL,HTP | tg128 | 46.4 ± 1 |
| qwen3 0.6B F16 | 852 MiB | OpenCL,HTP | pp1+tg5 | 47.8 ± 1 |
```
**47.8 tok/s = ~21ms/step** (vs 107ms CPU = 5× plus rapide)
## 6. Prochaines étapes
### Runner custom pour embeddings TTS
llama.cpp API supporte `llama_batch.embd` pour envoyer des embeddings
au lieu de token IDs. Il faut écrire un petit runner C++ qui :
1. Charge le GGUF avec le backend hexagon
2. Accepte des embeddings composites (1024 floats) via stdin/fichier
3. Retourne les logits (3072 floats) sur stdout/fichier
4. Gère le KV-cache entre les steps
### Intégration dans l'app
- Le runner tourne en subprocess root (comme le LLM)
- L'app envoie les embeddings composites et lit les logits
- Le sampling (temp=0.9, top_k=50) reste sur CPU côté app
### CP sur Hexagon NPU
- Même approche : convertir le CP (5 layers) en GGUF F16
- 5 layers → encore plus rapide que le talker
- Estimation : ~5ms pour les 17 steps CP

277
TTS_RAPPORT_COMPLET.md Normal file
View File

@ -0,0 +1,277 @@
# Rapport complet TTS Qwen3-TTS — Projet Kazeia
## Du point de départ au RTF 2.42 sur NPU Hexagon
### 2026-04-02
---
## 1. Point de départ
### Modèle choisi
**Qwen3-TTS-12Hz-0.6B-Base** (Alibaba/Qwen)
- 757M paramètres (talker) + 83M (code predictor) + decoder conv
- 12 Hz codec (12 frames/seconde), 16 codebooks RVQ par frame
- Voice cloning obligatoire via x-vector (pas de voix built-in)
- 10 langues : français, anglais, allemand, espagnol, japonais, chinois, coréen, russe, italien, portugais
### Architecture du pipeline
```
Texte → Tokenizer → Prefill (10 tokens)
[Boucle autoregressive × ~50 steps]
│ Talker (28 layers) → logits CB0 + hidden state
│ Sampling (temp=0.9, top_k=50, rep_penalty=1.05)
│ Code Predictor (5 layers × 17 passes) → CB1-CB15
│ Somme 16 embeddings + texte trailing → next input
└→ [Fin sur EOS token 2150]
VQ Decode → Speech Decoder (conv) → Audio PCM 24kHz
```
---
## 2. Bugs critiques découverts et corrigés
### Bug 1 : tts_pad manquant (LE bug critique)
- **Symptôme** : le modèle ne générait JAMAIS le token EOS, produisant 100+ tokens sans arrêt
- **Cause** : après épuisement des tokens texte, notre code envoyait des zéros. Le modèle Python envoie `tts_pad_embed`
- **Impact** : sans ce fix, aucun pipeline TTS ne pouvait fonctionner correctement
- **Correction** : une ligne → `nextEmbed = sumEmb(codecSum, padE)` au lieu de `nextEmbed = codecSum`
### Bug 2 : q_norm / k_norm oubliés dans le CP
- **Symptôme** : le CP exporté en KV-cache divergeait complètement au step 2
- **Cause** : l'attention du CP applique RMSNorm sur Q et K avant le rotary embedding. Notre wrapper manuel l'oubliait
- **Impact** : 0/15 codebooks corrects
- **Correction** : ajout de `attn.q_norm()` et `attn.k_norm()` dans le wrapper
### Bug 3 : Role prefill (assistant vs user)
- **Symptôme** : tokens incorrects dès le prefill
- **Cause** : le prefill utilisait le token "user" au lieu de "assistant" en mode voice cloning
- **Correction** : `TOKEN_ASSISTANT = 1042`
### Bug 4 : M-RoPE multimodal du talker
- **Symptôme** : le talker exporté produisait des logits différents du PyTorch
- **Cause** : le talker utilise M-RoPE avec `mrope_section=[24,20,20]` et `interleaved=True`, pas le RoPE standard
- **Correction** : pré-calcul des cos/sin avec `apply_interleaved_rope` et passage en inputs
### Bug 5 : DSP partagé (hexagon runner vs QNN decoder)
- **Symptôme** : le décodeur QNN crashait avec erreur 6031 après l'utilisation du runner hexagon
- **Cause** : le runner hexagon gardait une session HTP ouverte qui bloquait le QNN decoder
- **Correction** : QUIT le runner et `pkill` avant le décodage
---
## 3. Tentatives d'accélération NPU — Échecs instructifs
### 3.1 ONNX Runtime QNN EP (backend HTP)
| Config | Résultat | Cause |
|--------|----------|-------|
| Talker HTP default | EOS prématuré (1.4-2.2s) | Quantification int8/int16 automatique |
| Talker HTP "fp16" | Idem | L'option `enable_htp_fp16_precision` n'a aucun effet |
| CP HTP default | Pas d'EOS (185 tokens) | Codebooks corrompus |
| CP HTP fp16 | Idem | Même quantification destructive |
**Conclusion** : ONNX Runtime QNN EP quantifie TOUJOURS en int8/int16 via le QNN SDK, même avec les flags fp16. Le QNN SDK ne fait PAS de vrai fp16 IEEE-754.
### 3.2 ExecuTorch .pte (backend HTP)
| Config | Résultat | Cause |
|--------|----------|-------|
| Talker fp16 .pte | Silence (EOS OK mais audio vide) | fp16 HTP ≠ vrai fp16 |
| CP fp16 .pte | Bruit | Codebooks totalement faux |
| Talker 16a8w calibré | Inintelligible (EOS OK, 102 tokens) | Même avec calibration, pas assez précis |
| Talker split (NPU backbone + CPU lm_head) | EOS prématuré | Hidden states NPU déjà corrompus |
| Talker SmoothQuant + split | Inintelligible | SmoothQuant ne corrige pas la quantification HTP |
**Conclusion** : le HTP (via QNN SDK) est incompatible avec les modèles TTS autoregressifs. La quantification détruit la précision des codebooks, même en fp16, même avec calibration, même avec SmoothQuant.
### 3.3 GPU Adreno (ONNX Runtime QNN)
| Config | Résultat | Cause |
|--------|----------|-------|
| Talker GPU fp16 | **Audio parfait, tokens identiques** | Vrai fp16 IEEE-754 natif |
| Talker GPU fp32 | Audio parfait | fp32 natif |
**Mais** : vitesse GPU = vitesse CPU (130ms/step). L'overhead de transfert CPU↔GPU par token annule le gain. **Pas d'accélération.**
**Conclusion** : le GPU prouve que le vrai fp16 fonctionne pour le TTS. Le problème est le QNN SDK, pas le hardware.
---
## 4. La percée : ggml-hexagon (HMX FP16 natif)
### 4.1 Découverte
Le QNN SDK ne fait pas du vrai fp16 sur le HTP. Mais le hardware HMX (Hexagon Matrix eXtension) supporte nativement le fp16 IEEE-754. Le projet `htp-ops-lib` (Zixu Hao, EuroSys 2026) a reverse-engineeré les instructions HMX non documentées et les a intégrées dans llama.cpp via le backend `ggml-hexagon`.
### 4.2 Validation
```
Talker GGUF F16 sur Hexagon HMX :
- Top codec token : NPU=1739, CPU=1739 → MATCH EXACT
- Top 5 identiques : [1739, 1130, 808, 468, 663]
- Max logit diff : 0.0226
- Corrélation : 0.999998
- Vitesse : 48 tok/s = ~21ms/step (benchmark)
```
### 4.3 Implémentation
- **Conversion GGUF** : extraction des poids du talker en format Qwen3 GGUF F16 (852 MB)
- **Build** : llama.cpp compilé avec le toolchain Docker `ghcr.io/snapdragon-toolchain/arm64-android:v0.3`
- **Runner C++** : `tts-talker.cpp` (talker) et `tts-cp-runner.cpp` (CP), communication via Unix domain sockets
- **IPC** : sockets Unix entre l'app Kotlin et les runners root (chmod 666)
- **KV-cache** : talker persistant entre les tokens, CP reset via `llama_memory_clear` à chaque appel
### 4.4 Architecture finale
```
App Kotlin (user process)
├── Embedding computation (CPU, trivial)
├── Sampling (CPU, trivial)
├── Socket write/read (1ms overhead)
├── talker.sock ←→ llama-tts-talker (root, Hexagon HMX FP16)
│ └── 28 layers Qwen3, KV-cache persistant
├── cp.sock ←→ llama-tts-cp (root, Hexagon HMX FP16)
│ └── 5 layers Qwen3, 15 heads CPU matmul
└── ONNX Runtime QNN (HTP) pour le décodeur audio
├── pre_conv → preprocessor → conv_decoder
└── Exécuté APRÈS que les runners hexagon sont stoppés (DSP partagé)
```
---
## 5. Évolution des performances
| Étape | Talker | CP | Decode | RTF | Date |
|-------|--------|-----|--------|-----|------|
| CPU pur (ONNX, 4 threads) | 130ms | 350ms (fullseq) | 3s NPU | 7.0 | 01/04 |
| + 6 threads + CP KV-cache | 107ms | 202ms | 3s | 4.95 | 01/04 |
| + Talker Hexagon NPU (fichiers) | 42ms | 201ms | 3s | 3.94 | 02/04 |
| + CP Hexagon NPU (fichiers) | 42ms | 168ms | 3s | 3.73 | 02/04 |
| + **Socket IPC** | **27ms** | **88ms** | **3.5s** | **2.42** | **02/04** |
| + memory_clear CP | 27ms | 85ms | 3.5s | **2.42** | 02/04 |
**Gain total : RTF 7.0 → 2.42 = 2.9× plus rapide**
### Décomposition du temps (steady state, ~52 tokens)
```
Génération : 6.0s (60% du total)
├── Talker HMX : 27ms/step × 52 = 1.4s (23% de la gen)
└── CP HMX+CPU : 88ms/step × 52 = 4.6s (77% de la gen)
├── 17 × llama_decode NPU : ~68ms
└── 15 × head matmul CPU : ~15ms
└── Context clear + IPC : ~5ms
Prefill : 0.3s (3%)
Decode NPU : 3.5s (35%)
└── VQ lookup + pre_conv + preprocessor + conv_decoder
Runner startup : 14s talker + 13s CP (one-time au lancement app)
```
---
## 6. Tentatives avortées ou en suspens
### Streaming (play pendant la génération)
- **Problème** : le décodeur QNN et les runners hexagon ne peuvent pas coexister sur le DSP
- **Status** : le `synthesizeAndPlay` en mode streaming bloque sur le decode QNN
- **Solution possible** : decoder sur GPU Adreno ou CPU (pas le HTP)
### CP sur le même process que le talker (dual-model)
- **Problème** : deux contextes llama dans le même process se marchent dessus sur le HTP
- **Solution** : deux processes séparés (fonctionnel)
### NeuTTS Air
- **Testé** : chargement OK, génération RTF 1.04 sur PC x86
- **Status** : non intégré, qualité français à valider
- **Intérêt** : single codebook FSQ → potentiellement compatible NPU quantifié
### Quantification calibrée (16a8w)
- **Données** : 2618 tenseurs talker + 39270 CP collectés (10 langues, 50 phrases)
- **Status** : testé, inintelligible. La calibration ne suffit pas pour le TTS RVQ
---
## 7. Fichiers et déploiement
### Sur le PC (/opt/Kazeia/)
| Fichier | Description | Taille |
|---------|-------------|--------|
| `models_qnn/talker_f16.gguf` | Talker GGUF pour Hexagon | 852 MB |
| `models_qnn/cp_f16.gguf` | CP GGUF pour Hexagon | 158 MB |
| `models_qnn/cp_heads.bin` | 15 lm_heads du CP | 120 MB |
| `models_qnn/cp_codec_embs.bin` | 15 embedding tables CP | 120 MB |
| `llama.cpp/build-snapdragon/` | Build ARM64 avec Hexagon | |
| `llama.cpp/examples/tts-talker/` | Source des runners | |
| `models_qnn/calibration_data/` | Données de calibration | 338 MB |
| `TTS_RAPPORT_COMPLET.md` | Ce rapport | |
| `TTS_HEXAGON_NPU_GUIDE.md` | Guide Hexagon | |
| `TTS_GPU_GUIDE.md` | Guide GPU Adreno | |
### Sur la tablette (/data/local/tmp/kazeia/)
| Chemin | Description |
|--------|-------------|
| `llama-hex/llama-tts-talker` | Runner talker ARM64 |
| `llama-hex/llama-tts-cp` | Runner CP ARM64 |
| `llama-hex/libggml-htp-v79.so` | Skel Hexagon v79 (HMX FP16) |
| `llama-hex/lib*.so` | Libs llama.cpp |
| `models/talker_f16.gguf` | Talker GGUF |
| `models/cp_f16.gguf` | CP GGUF |
| `models/cp_heads.bin` | Heads CP |
| `models/cp_codec_embs.bin` | Embeddings CP |
| `models/qwen3-tts-npu/` | Modèles ONNX + embeddings |
| `talker.sock` | Socket Unix talker |
| `cp.sock` | Socket Unix CP |
### Dans l'app (kazeia-android/)
| Fichier | Description |
|---------|-------------|
| `app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt` | Moteur TTS complet |
| `app/src/main/jniLibs/arm64-v8a/libQnnHtp.so` | QNN HTP pour decoder |
| `app/src/main/jniLibs/arm64-v8a/libQnnGpu.so` | QNN GPU (validé, pas utilisé) |
| `app/src/main/jniLibs/arm64-v8a/libexecutorch.so` | ExecuTorch JNI (validé, pas utilisé) |
---
## 8. Leçons apprises
1. **Le QNN SDK ment sur le fp16** — il quantifie toujours en int8/int16 même avec `use_fp16=True`. Le vrai fp16 n'est accessible que via les instructions HMX reverse-engineerées (ggml-hexagon)
2. **Le GPU Adreno fait du vrai fp16** — tokens identiques au CPU, prouvant que le fp16 IEEE-754 est suffisant pour le TTS. C'est le GPU qui nous a donné la preuve que le problème était le QNN SDK, pas la précision fp16
3. **Les modèles TTS RVQ sont incompatibles avec la quantification** — contrairement aux LLM qui tolèrent int4, le TTS avec 16 codebooks RVQ est détruit par la moindre erreur de quantification. L'argmax sur 2048 valeurs avec des marges fines ne pardonne pas
4. **ggml-hexagon est la clé** — les kernels HMX reverse-engineerés de htp-ops-lib donnent accès à 12 TFLOPS fp16 natif, contournant complètement le QNN SDK. Le même NPU, des résultats radicalement différents
5. **Deux processes sur le même HTP fonctionnent** — contrairement au dual-model dans le même process, deux processes séparés avec chacun leur contexte HTP coexistent
6. **Les sockets Unix sont 5× plus rapides que les fichiers** — IPC par fichier ajoutait ~50ms par call, les sockets réduisent à ~1ms
7. **Le DSP est un goulot de partage** — le runner hexagon et le décodeur QNN ne coexistent pas bien, même séquentiellement. Le streaming est bloqué par ce conflit
---
## 9. État actuel et prochaines étapes
### Performance actuelle
- **RTF 2.42** (10s pour 4s audio, hors cold start)
- **Audio parfait**, tokens identiques au CPU, EOS naturel
- **Voice cloning** fonctionnel (voix Damien)
- **Cold start** : ~28s (chargement runners + modèles)
### Architecture cible compatible Unity
```
LLM Qwen3-0.6B → NPU HTP INT4 (ExecuTorch, 93 tok/s)
Whisper STT → NPU HTP (ONNX Runtime QNN)
TTS Talker → Hexagon HMX FP16 (ggml-hexagon, 37 tok/s)
TTS CP → Hexagon HMX FP16 (ggml-hexagon, 11 tok/s)
TTS Decoder → NPU HTP (ONNX Runtime QNN, séquentiel après runners)
Silero VAD → CPU
Unity Avatar 3D → GPU Adreno 100% libre
```
### Optimisations restantes
1. **Streaming** : decoder sur CPU ou GPU pour éviter le conflit DSP → premier son à ~7s au lieu de ~10s
2. **Cold start** : pré-charger les runners au boot de l'app, pas à chaque génération
3. **CP optimisation** : batched prefill (2 tokens), head matmuls sur NPU
4. **BPE tokenizer** : remplacer les phrase_embeds pré-calculés par un vrai tokenizer
5. **Multi-voix** : supporter plusieurs x-vectors pour différents personnages

177
TTS_REPORT.md Normal file
View File

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

View File

@ -0,0 +1,184 @@
# Compiling Whisper for Qualcomm NPU (AI Hub)
Guide pour compiler les modèles Whisper (Base/Small/Medium) via Qualcomm AI Hub
pour déploiement NPU sur Snapdragon 8 Elite (SM8750).
## Prérequis
### Environnement Python
```bash
# Créer un venv dédié (Python 3.10 recommandé)
python3 -m venv /opt/Kazeia/qnn_venv
source /opt/Kazeia/qnn_venv/bin/activate
# Installer qai-hub et les modèles
pip install qai-hub qai-hub-models
```
### Compte Qualcomm AI Hub
1. Créer un compte sur https://aihub.qualcomm.com
2. Générer un API token dans les paramètres du compte
3. Configurer le token :
```bash
qai-hub configure --api_token <VOTRE_TOKEN>
```
La configuration est sauvée dans `~/.qai_hub/client.ini`.
## Compilation
### Commande générale
```bash
source /opt/Kazeia/qnn_venv/bin/activate
python3 -m qai_hub_models.models.<MODEL_NAME>.export \
--device "Snapdragon 8 Elite QRD" \
--target-runtime precompiled_qnn_onnx \
--precision float \
--skip-profiling \
--skip-inferencing \
--output-dir /opt/Kazeia/models_qnn/<OUTPUT_DIR>
```
### Modèles disponibles
| Modèle | Module | Paramètres | Layers | Heads |
|--------|--------|------------|--------|-------|
| Whisper-Base | `whisper_base` | 77M | 6 | 8 |
| Whisper-Small | `whisper_small` | 244M | 12 | 12 |
| Whisper-Medium | `whisper_medium` | 769M | 24 | 16 |
### Exemple : Whisper-Small
```bash
python3 -m qai_hub_models.models.whisper_small.export \
--device "Snapdragon 8 Elite QRD" \
--target-runtime precompiled_qnn_onnx \
--precision float \
--skip-profiling \
--skip-inferencing \
--output-dir /opt/Kazeia/models_qnn/whisper-small-sm8750
```
**Temps de compilation** : ~5-10 minutes (upload modèle + compilation cloud).
### Exemple : Whisper-Base
```bash
python3 -m qai_hub_models.models.whisper_base.export \
--device "Snapdragon 8 Elite QRD" \
--target-runtime precompiled_qnn_onnx \
--precision float \
--skip-profiling \
--skip-inferencing \
--output-dir /opt/Kazeia/models_qnn/whisper-base-sm8750
```
### Options de device
```bash
# Lister les devices disponibles
qai-hub list-devices | grep -i "8 elite\|sm8750"
# Devices SM8750 (Snapdragon 8 Elite) :
# "Snapdragon 8 Elite QRD" → chipset: qualcomm-snapdragon-8-elite, sm8750
# "Samsung Galaxy S25 (Family)" → chipset: qualcomm-snapdragon-8-elite-for-galaxy, sm8750-ac
#
# Note: OnePlus Pad 2/3 utilise sm8750 standard (pas "for-galaxy")
```
## Fichiers générés
La compilation produit un sous-dossier avec :
```
whisper_small-precompiled_qnn_onnx-float-qualcomm_snapdragon_8_elite/
├── HfWhisperEncoder.onnx # Stub ONNX (~3KB)
├── HfWhisperEncoder_qairt_context.bin # Context binaire QNN (encoder)
├── HfWhisperDecoder.onnx # Stub ONNX (~8KB)
├── HfWhisperDecoder_qairt_context.bin # Context binaire QNN (decoder)
├── metadata.yaml # Métadonnées de compilation
└── vocab.bin # Vocabulaire (non utilisé)
```
### Architecture des modèles
**Encoder** (HfWhisperEncoder) :
- Input : `input_features` [1, 80, 3000] fp16
- Output : Cross-attention KV caches (N layers × k,v)
- `k_cache_cross_N` : [num_heads, 1, 64, 1500] fp16
- `v_cache_cross_N` : [num_heads, 1, 1500, 64] fp16
**Decoder** (HfWhisperDecoder) — KV-cache autorégressif :
- Inputs :
- `input_ids` : [1, 1] int32 (un token à la fois)
- `attention_mask` : [1, 1, 1, 200] fp16 (right-aligned, -100 pour masqué)
- `k_cache_self_N_in` / `v_cache_self_N_in` : Self KV caches (199 slots, init zeros)
- `k_cache_cross_N` / `v_cache_cross_N` : Cross KV caches (depuis encoder)
- `position_ids` : [1] int32
- Outputs :
- `logits` : [1, 51865, 1, 1] fp16
- `k_cache_self_N_out` / `v_cache_self_N_out` : Self KV caches mis à jour
## Déploiement sur tablette
### 1. Copier les modèles
```bash
MODEL_DIR="/data/local/tmp/kazeia/models/whisper-small-sm8750"
SRC="<chemin_local>/whisper_small-precompiled_qnn_onnx-float-qualcomm_snapdragon_8_elite"
adb shell "mkdir -p $MODEL_DIR"
adb push $SRC/HfWhisperEncoder.onnx $MODEL_DIR/
adb push $SRC/HfWhisperEncoder_qairt_context.bin $MODEL_DIR/
adb push $SRC/HfWhisperDecoder.onnx $MODEL_DIR/
adb push $SRC/HfWhisperDecoder_qairt_context.bin $MODEL_DIR/
```
### 2. Copier les assets partagés
Les fichiers `mel_filters.json` et `vocab.json` sont communs à toutes les variantes Whisper :
```bash
# Si déjà présents depuis une autre variante :
adb shell "cp /data/local/tmp/kazeia/models/whisper-sm8750/mel_filters.json $MODEL_DIR/"
adb shell "cp /data/local/tmp/kazeia/models/whisper-sm8750/vocab.json $MODEL_DIR/"
```
### 3. Configurer le chemin dans KazeiaService.kt
```kotlin
npuStt.load("$modelsDir/whisper-small-sm8750")
```
## Tailles des modèles compilés
| Modèle | Encoder (bin) | Decoder (bin) | Total |
|--------|--------------|---------------|-------|
| Whisper-Base | 47 MB | 145 MB | ~192 MB |
| Whisper-Small | 201 MB | 345 MB | ~546 MB |
## Performances sur OnePlus Pad (SM8750)
| Étape | Whisper-Base | Whisper-Small |
|-------|-------------|---------------|
| Mel (C++ natif) | ~220ms | ~220ms |
| Encoder NPU | ~140ms | ~270ms* |
| Decoder NPU (par step) | ~13ms | TBD |
| Load encoder | ~150ms | ~270ms |
| Load decoder | ~150ms | ~250ms |
*À confirmer avec des benchmarks réels sur audio.
## Dépannage
### "resource failed to call close"
Warnings bénins de l'ONNX Runtime. Les sessions ORT non fermées proprement génèrent ces messages lors du GC.
### Modèle ne se charge pas
- Vérifier que `HfWhisperEncoder_qairt_context.bin` est dans le MÊME répertoire que `HfWhisperEncoder.onnx`
- Vérifier que `libQnnHtp.so` est accessible via `nativeLibDir`
- Le modèle doit être compilé pour le bon chipset (sm8750, pas sm8750-ac ni sm8850)
### Mauvaise détection de langue
Whisper-Base est peu fiable pour la détection de langue sur segments courts. Whisper-Small est nettement meilleur. Si besoin, on peut forcer le token de langue après SOT.
## Référence
- Qualcomm AI Hub : https://aihub.qualcomm.com
- Code de référence : `qai_hub_models/models/_shared/hf_whisper/app.py`
- HuggingFace Whisper-Base : https://huggingface.co/qualcomm/Whisper-Base
- HuggingFace Whisper-Small : https://huggingface.co/qualcomm/Whisper-Small

View File

@ -0,0 +1,243 @@
# Rapport TTS On-Device NPU — Problèmes et Solutions
**Date** : 29 mars 2026
**Contexte** : Kazeia — chatbot émotionnel on-device sur OnePlus Pad 3 (Snapdragon 8 Elite, HTP V79)
**Objectif** : TTS multilingue avec voice cloning, entièrement sur NPU
---
## 1. Exigences
| Critère | Requis |
|---------|--------|
| **Multilingue** | Français + anglais minimum |
| **Voice cloning** | Cloner une voix à partir d'un échantillon WAV (~5-10s) |
| **On-device** | Aucun appel réseau, tout local |
| **NPU** | Le composant le plus lourd doit tourner sur le HTP Qualcomm |
| **Latence** | < 3s pour une phrase courte (temps réel acceptable) |
| **Qualité** | Voix naturelle, intelligible, prosodie correcte |
---
## 2. Candidats évalués
### 2.1 Chatterbox Multilingual (ONNX)
| | Détail |
|---|---|
| **Source** | `onnx-community/chatterbox-multilingual-ONNX` |
| **Architecture** | Speech Encoder (591 MB) + Embed Tokens (68 MB) + Language Model 30L (291-2000 MB) + Conditional Decoder (534 MB) |
| **Multilingue** | Oui (23 langues, tag `[fr]`, `[en]`, etc.) |
| **Voice cloning** | Oui (speaker embedding extrait de l'audio de référence) |
| **Format** | ONNX (FP32, FP16, Q4F16) |
### 2.2 Qwen3-TTS 0.6B Base (PyTorch)
| | Détail |
|---|---|
| **Source** | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` |
| **Architecture** | Speaker Encoder (8.9M) + Talker LM 28L (754M) + Code Predictor 5L (141M) + Speech Decoder (114M) |
| **Multilingue** | Oui (français, anglais, etc.) |
| **Voice cloning** | Oui (x-vector du speaker encoder) |
| **Format** | PyTorch natif (le Talker LM est aussi exporté en ExecuTorch .pte) |
---
## 3. Problèmes rencontrés
### 3.1 Chatterbox — Opérateurs ONNX non standard
**Problème central** : Les modèles ONNX de Chatterbox utilisent des **opérateurs Microsoft custom** qui ne sont supportés ni par QNN (Qualcomm) ni par AI Hub :
| Opérateur | Domaine | Utilisations | Problème |
|-----------|---------|-------------|----------|
| `GroupQueryAttention` | `com.microsoft` | 30 (1 par couche) | Non supporté par QNN/AI Hub |
| `SkipSimplifiedLayerNormalization` | `com.microsoft` | 60 | Non supporté par QNN/AI Hub |
| `SimplifiedLayerNormalization` | ONNX opset 21 | 1 | Non supporté par QNN (opset trop récent) |
Ces opérateurs sont des **optimisations internes d'ONNX Runtime** (fusion GQA, skip-connection + layernorm fusionnés). Ils fonctionnent sur CPU via ORT mais ne peuvent pas être compilés pour le NPU Qualcomm.
**Conséquence** : Le language model (30 couches, ~85% du temps de calcul) tourne entièrement sur **CPU** à ~1 tok/s sur la tablette. Sur PC, il tourne à ~45 tok/s (CPU x86 plus puissant).
**Tentatives de résolution** :
1. ✅ Compilation AI Hub avec opset 21 → Échec (`SimplifiedLayerNormalization` non supporté)
2. ✅ Patch opset 21→17 + remplacement LayerNorm → Échec (`int64` non supporté)
3. ✅ Ajout `--truncate_64bit_io` → Échec (`GroupQueryAttention` non supporté)
4. ❌ Le modèle FP32 utilise aussi `GroupQueryAttention`
5. ❌ Le modèle Q4F16 utilise aussi `GroupQueryAttention`
**Solution potentielle** : Retrouver le modèle PyTorch original de Chatterbox et le ré-exporter en ONNX avec des opérateurs standard (attention multi-head classique au lieu de GQA fusionné). Le modèle source est sur HuggingFace (`resemble-ai/chatterbox-multilingual`) mais l'export ONNX standard n'a pas été publié.
**Autre problème constaté** : La variante Q4F16 (quantifiée INT4) produit de l'audio de **mauvaise qualité** sur la tablette — le son "ne correspond à rien" selon le test utilisateur. Sur PC, le même modèle Q4F16 fonctionne correctement (63 tokens, stop token atteint, 2.5s d'audio). La différence pourrait venir de la précision des opérations INT4 sur ARM vs x86.
### 3.2 Qwen3-TTS — Speech Decoder non exportable
**Problème central** : Le pipeline Qwen3-TTS est composé de 4 modules dont **seuls 2 sont exportables** :
| Module | Export ONNX | Export ExecuTorch | Bloqueur |
|--------|------------|-------------------|----------|
| **Speaker Encoder** (8.9M) | ⚠️ Non testé (probablement OK) | Non tenté | Conv1D simple |
| **Talker LM** (754M) | ❌ Échoue | ✅ **Fonctionne** (90.7 tok/s NPU) | — |
| **Code Predictor** (141M) | ✅ **Exporté** (440 MB) | Non tenté | — |
| **Speech Decoder** (114M) | ❌ **Échoue** | ❌ Échoue | `SplitResidualVectorQuantizer` + `SnakeBeta` |
Le **Speech Decoder** est le bloqueur. Il contient :
1. **`SplitResidualVectorQuantizer`** : Utilise `torch.autograd.Function` avec `vmap` — une fonctionnalité PyTorch avancée incompatible avec tout export (ONNX legacy, dynamo, jit.trace). C'est le composant qui convertit les indices de codebook en vecteurs continus.
2. **`SnakeBeta`** activation : Bien que son `forward()` soit du PyTorch standard (`x + sin²(αx)/β`), elle est utilisée dans des blocs qui contiennent aussi le VQ, rendant l'export impossible pour l'ensemble.
**Tentatives de résolution** :
1. ✅ Export ONNX legacy (`torch.onnx.export`) → `RuntimeError: unordered_map::at` (vmap)
2. ✅ Export dynamo (`torch.onnx.export(dynamo=True)`) → Échec (strict et non-strict)
3. ✅ Export TorchScript (`torch.jit.trace`) → `RuntimeError: unordered_map::at`
4. ✅ Décomposition en sous-modules (pre_conv, pre_transformer, conv_decoder) → Le VQ bloque toujours
5. ✅ Export du code predictor seul → Réussi (mais inutile sans le speech decoder)
**Solution potentielle** : Réécrire le `SplitResidualVectorQuantizer.decode()` en opérations PyTorch basiques (embedding lookups + Conv1d projections) sans utiliser `torch.autograd.Function` ni `vmap`. Les poids des codebooks ont été extraits en numpy. Cela demande de comprendre précisément le flow de données du VQ decode.
---
## 4. Résumé comparatif
| Critère | Chatterbox ONNX | Qwen3-TTS |
|---------|----------------|-----------|
| **Multilingue** | ✅ 23 langues | ✅ Multilingue |
| **Voice cloning** | ✅ | ✅ (x-vector) |
| **Fonctionne sur CPU tablette** | ✅ (très lent, ~1 tok/s) | ❌ (nécessite PyTorch = Termux) |
| **NPU compilable** | ❌ (ops Microsoft custom) | ⚠️ Partiel (Talker OK, decoder bloqué) |
| **Qualité Q4F16** | ⚠️ Mauvaise sur ARM | N/A |
| **Qualité FP16/FP32** | ✅ Bonne (PC) | ✅ Bonne (PC) |
| **Taille totale** | ~1.5 GB (Q4F16) | ~1.0 GB (Talker .pte + reste) |
| **Vitesse estimée NPU** | ~45 tok/s (si compilable) | ~90 tok/s (Talker déjà validé) |
---
## 5. Chemins de résolution
### Option A : Ré-exporter Chatterbox depuis PyTorch (recommandé)
**Principe** : Charger le modèle PyTorch original (`resemble-ai/chatterbox-multilingual`), désactiver les optimisations ORT, et exporter en ONNX standard.
**Avantages** :
- Le pipeline complet est déjà implémenté dans l'app Android (`ChatterboxTtsEngine.kt`)
- Speech encoder, embed tokens, et conditional decoder tournent déjà sur CPU (petits, rapides)
- Seul le language model a besoin du NPU
**Étapes** :
1. Charger `resemble-ai/chatterbox-multilingual` en PyTorch
2. Exporter le language model en ONNX opset 17 avec attention standard (pas GQA fusionné)
3. Compiler via AI Hub pour SM8750
4. Remplacer le `language_model_q4f16.onnx` par la version QNN precompiled
5. Les 3 autres modèles restent en ONNX CPU
**Risques** : Le modèle PyTorch original pourrait ne pas être public ou avoir une architecture différente des ONNX publiés.
**Estimation** : 2-4h de travail si le modèle PyTorch est accessible.
### Option B : Réécrire le VQ decode de Qwen3-TTS
**Principe** : Remplacer le `SplitResidualVectorQuantizer` par des opérations ONNX-compatibles (embedding lookups).
**Avantages** :
- Le Talker tourne déjà à 90 tok/s sur NPU
- Le Code Predictor est déjà exporté en ONNX
- Qualité TTS supérieure (Qwen3 est plus récent)
**Étapes** :
1. Analyser le flow de `quantizer.decode()` (codebook lookup + projection + sommation)
2. Réimplémenter en PyTorch sans `vmap` ni `autograd.Function`
3. Exporter le speech decoder complet en ONNX
4. Intégrer dans l'app Android
**Risques** : La réimplémentation du VQ pourrait introduire des différences numériques affectant la qualité audio.
**Estimation** : 4-8h de travail.
### Option C : Chatterbox CPU avec optimisations
**Principe** : Garder Chatterbox sur CPU mais optimiser :
- Utiliser NNAPI EP au lieu de CPU pur (délègue certaines ops au DSP)
- Réduire le nombre de tokens max (limiter à ~50 tokens au lieu de 512)
- Pré-encoder les voix au premier lancement (éviter le coût du speech encoder)
**Avantages** : Pas de recompilation nécessaire, fonctionne maintenant.
**Inconvénients** : Toujours lent (~1-5 tok/s), latence de 10-30s par phrase.
### Option D : TTS léger (Piper) comme solution intermédiaire
**Principe** : Utiliser Piper TTS (VITS, ~30 MB) pour avoir du TTS français fonctionnel immédiatement, en parallèle du travail sur Chatterbox/Qwen3-TTS NPU.
**Avantages** :
- Modèles ONNX standard, très légers
- Latence ~100ms
- Français disponible
- Pas de compilation NPU nécessaire
**Inconvénients** :
- Pas de voice cloning
- Qualité inférieure (voix synthétique)
- Une seule voix par modèle
---
## 6. Recommandation
**Court terme** : Option A (ré-export Chatterbox PyTorch) est la voie la plus prometteuse. Le pipeline Android est déjà prêt, seul le language model a besoin du NPU. Si le modèle PyTorch est accessible, c'est réalisable rapidement.
**Moyen terme** : Option B (Qwen3-TTS VQ rewrite) donnerait les meilleures performances (Talker déjà à 90 tok/s NPU) mais demande plus de travail d'ingénierie.
**Fallback** : Option D (Piper) comme TTS temporaire pendant le développement NPU.
---
## 7. Fichiers et ressources disponibles
### Modèles Chatterbox (sur serveur)
```
/opt/Kazeia/models_qnn/chatterbox-tts/onnx/
├── speech_encoder.onnx (+data, 591 MB)
├── embed_tokens.onnx (+data, 68 MB)
├── language_model.onnx (+data, 2081 MB FP32)
├── language_model_fp16.onnx (+data, 1040 MB)
├── language_model_q4f16.onnx (+data, 305 MB)
└── conditional_decoder.onnx (+data, 534 MB)
```
### Modèles Qwen3-TTS (sur serveur)
```
/opt/Kazeia/models_qnn/qwen3-tts-executorch/
├── hybrid_llama_qnn.pte (286 MB, Talker NPU ✅)
└── tokenizer.json
/opt/Kazeia/models_qnn/qwen3-tts-onnx/
├── code_predictor_transformer.onnx (314.8 MB ✅)
├── code_predictor_heads.onnx (125.8 MB ✅)
├── code_predictor_embeddings.npy
└── speech_decoder_pre_conv.onnx (6.3 MB ✅)
/opt/Kazeia/models_qnn/qwen3-tts-native/
├── speech_decoder_weights.pt (437 MB)
├── code_predictor_weights.pt (541 MB)
├── speaker_encoder_weights.pt (34 MB)
└── text_components.pt (1.2 GB)
```
### Voix de référence (sur tablette)
```
/data/local/tmp/kazeia/voix/
├── damien.wav, elodie.wav, jerome.wav, richard.wav
├── amir.wav, didier.wav, sid.wav, zelda.wav
```
### Code Android
```
app/src/main/java/com/kazeia/tts/
├── ChatterboxTtsEngine.kt (pipeline complet, KV-cache, voice cloning)
├── AndroidTtsEngine.kt (fallback Google TTS)
```
---
*Rapport généré par Claude Code (Opus 4.6) — Projet Kazeia*

View File

@ -0,0 +1,135 @@
# Tests Qwen3-TTS sur OnePlus Pad 3 — Journal
**Date** : 29 mars 2026
---
## Environnement
- **Tablette** : OnePlus Pad 3 (Snapdragon 8 Elite, 16 GB RAM)
- **Runtime** : Termux + Python 3.12 + PyTorch 2.9.0 (Termux native ARM)
- **Modèle** : Qwen3-TTS-12Hz-0.6B-Base (local, `/data/local/tmp/kazeia/models/qwen3-tts/`)
- **Dépendances** : transformers 4.57.3, torchaudio 2.9.0, soundfile, einops
- **Mocks** : librosa (soundfile+scipy), soxr (scipy), sox, onnxruntime
## Résultats des tests
### Test 1 : float32 complet
- **Résultat** : OOM (killed) — le modèle 1.7 GB + speech tokenizer 651 MB + overhead dépassent la RAM disponible
- **RAM utilisée** : >10 GB avant crash
### Test 2 : float16
- **Résultat** : NaN dans le code predictor (`RuntimeError: probability tensor contains either inf, nan or element < 0`)
- **Cause** : float16 n'a pas assez de précision pour le softmax du code predictor (5 couches)
### Test 3 : float16 + code predictor float32
- **Résultat** : dtype mismatch (`RuntimeError: expected m1 and m2 to have the same dtype, but got: float != c10::Half`)
- **Cause** : le code predictor en float32 reçoit des tenseurs float16 du talker — les types ne sont pas automatiquement castés dans le forward couplé
### Test 4 : bfloat16 ✅
- **Résultat** : **Fonctionne**
- **"Bonjour."** : 39.5s pour 1.0s d'audio (RTF 39.5x)
- **"Bonjour, je suis là pour vous écouter."** : 109.4s pour 2.6s d'audio (RTF 41.5x)
- **Explication** : bfloat16 a le même range que float32 (8 bits d'exposant) mais moins de mantisse. Le code predictor ne produit plus de NaN.
- **RAM** : ~3.8 GB (modèle) + ~1-2 GB (inference) = ~5-6 GB total
### Test 5 : INT8 dynamic quantization
- **Résultat** : Échec (`NoQEngine` — le backend quantization n'est pas compilé dans la version Termux de PyTorch)
### Test 6 : torch.compile
- **Résultat** : OOM — l'overhead de compilation consomme trop de RAM
### Test 7 : Speaker encoder timing
- **Sur PC** : 2-10s selon la voix
- **Sur tablette CPU** : **688s (11 min)** — inutilisable
- **Solution** : Pré-calculer les embeddings sur PC, les stocker en .npy (4 KB chacun), les charger instantanément
## Architecture validée
```
[PC - pré-calcul]
Voix WAV → Speaker Encoder → embedding .npy (1024 floats, 4 KB)
[Tablette - runtime]
embedding .npy (instantané)
+ texte
Talker LM (28 couches, bfloat16 CPU) → speech tokens
Code Predictor (5 couches, bfloat16) → 15 codebooks
Speech Decoder (Transformer + VQ + ConvNet) → audio WAV
```
## Performances actuelles (CPU bfloat16, 6 threads)
| Phrase | Tokens | Temps | Audio | RTF |
|--------|--------|-------|-------|-----|
| "Bonjour." | ~20 | 39.5s | 1.0s | 39.5x |
| "Bonjour, je suis là..." | ~50 | 109.4s | 2.6s | 41.5x |
**Goulot d'étranglement** : Le talker (28 couches transformer autorégressif) représente ~90% du temps.
## Estimation avec NPU
Le talker .pte a été testé à **90.7 tok/s** sur le NPU Hexagon (rapport précédent).
Sur CPU bfloat16, le talker fait ~0.5 tok/s (estimé d'après les temps).
| Composant | CPU actuel | NPU estimé |
|-----------|-----------|------------|
| Talker (50 tokens) | ~100s | **~0.6s** |
| Code predictor | ~3s | ~3s (CPU) |
| Speech decoder | ~6s | ~6s (CPU) |
| **Total** | **~109s** | **~10s** |
## Blocages pour l'intégration NPU
1. **`qnn_llama_runner` incompatible** : Le runner prend du texte brut et utilise un TEXT tokenizer. Le talker TTS attend des embeddings texte pré-calculés (via `text_projection`) + un speaker embedding. Les entrées/sorties ne correspondent pas.
2. **ExecuTorch Python pas dispo sur Termux** : Le package pip `executorch` n'a pas de wheel ARM. La compilation locale nécessiterait le NDK + CMake cross-compilation.
3. **Couplage talker ↔ code predictor** : Le code predictor est appelé à CHAQUE step du talker (pas après). Ses sorties (15 codebooks) sont ré-injectées dans le talker comme embeddings pour le step suivant.
## Solutions en cours d'exploration
### A. Service TTS résident (CPU bfloat16)
Script Python (`tts_service.py`) qui reste en mémoire avec le modèle chargé. L'app Android écrit une requête JSON, le service génère le WAV.
- **Avantage** : Fonctionne maintenant (validé)
- **Inconvénient** : ~40-110s par phrase (inutilisable en production)
### B. Compiler ExecuTorch Python pour Termux/ARM
Cross-compiler le binding Python ExecuTorch pour aarch64-android. Permettrait de charger le `.pte` et faire les forward passes sur NPU directement depuis Python.
- **Avantage** : Garderait le couplage talker ↔ code predictor
- **Difficulté** : Compilation cross-platform complexe
### C. Runner C++ custom pour le talker TTS
Modifier `qnn_llama_runner` pour accepter des embeddings pré-calculés au lieu de texte, et sortir des token IDs bruts.
- **Avantage** : Réutilise l'infra ExecuTorch existante
- **Difficulté** : Modification C++ du runner
### D. Pipeline découplé (talker NPU → code predictor CPU)
Accepter une qualité légèrement réduite en découplant : le talker NPU génère codebook 0, puis le code predictor génère codebooks 1-14 en un seul pass (pas step-by-step).
- **Avantage** : Plus simple à implémenter
- **Inconvénient** : Qualité potentiellement dégradée
## Fichiers déployés sur la tablette
```
/data/local/tmp/kazeia/
├── models/qwen3-tts/
│ ├── config.json, model.safetensors (1.7 GB)
│ ├── speech_tokenizer/model.safetensors (651 MB)
│ ├── tokenizer_config.json, vocab.json, merges.txt
│ └── voice_embeddings/
│ ├── damien_spk_embedding.npy (4 KB)
│ ├── elodie_spk_embedding.npy
│ └── ... (8 voix)
├── tts_service.py
├── tts_test.wav (dernier test)
└── kazeia-et/
└── hybrid_llama_qnn.pte (286 MB, talker NPU)
```
---
*Journal de tests — Claude Code (Opus 4.6)*

View File

@ -0,0 +1,100 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.kazeia"
compileSdk = 36
ndkVersion = "27.3.13750724"
defaultConfig {
applicationId = "com.kazeia"
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "0.1.0-mvp"
ndk {
abiFilters += "arm64-v8a"
}
externalNativeBuild {
cmake {
cppFlags += "-std=c++17"
arguments += "-DANDROID_STL=c++_shared"
}
}
}
externalNativeBuild {
cmake {
path = file("src/main/jni/CMakeLists.txt")
version = "3.22.1"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
}
dependencies {
// Android
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Lifecycle (StateFlow observation)
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
// ONNX Runtime (for Silero VAD)
implementation("com.microsoft.onnxruntime:onnxruntime-android-qnn:1.24.3")
// LiteRT + QNN (for Whisper NPU)
implementation("com.google.ai.edge.litert:litert:1.4.2")
implementation("com.google.ai.edge.litert:litert-api:1.4.2")
implementation("com.qualcomm.qti:qnn-litert-delegate:2.44.0")
implementation("com.qualcomm.qti:qnn-runtime:2.44.0")
// ExecuTorch JNI dependencies (for TTS CP on NPU)
implementation("com.facebook.fbjni:fbjni:0.7.0")
implementation("com.facebook.soloader:nativeloader:0.10.5")
implementation(files("libs/executorch.jar"))
// Unity as a Library (UaaL) — DISABLED
// implementation(project(":unityLibrary"))
// implementation("androidx.games:games-activity:3.0.5")
// compileOnly(files("../unityLibrary/unityLibrary/libs/unity-classes.jar"))
}

3
kazeia-android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,3 @@
# Kazeia ProGuard rules
-keep class com.kazeia.llm.GenieJni { *; }
-keep class ai.onnxruntime.** { *; }

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:name=".KazeiaApplication"
android:largeHeap="true"
android:extractNativeLibs="true"
android:label="Kazeia"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.Kazeia">
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
<!-- LAUNCHER: Splash screen (loads ML models) -->
<activity
android:name=".ui.SplashActivity"
android:exported="true"
android:screenOrientation="unspecified"
android:theme="@style/Theme.Kazeia.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Unity AvatarActivity — DISABLED -->
<!--
<activity
android:name=".avatar.AvatarActivity"
android:exported="false" />
-->
<!--
LEGACY: ChatActivity kept for fallback / non-avatar mode.
Not used in normal flow (SplashActivity -> AvatarActivity).
-->
<activity
android:name=".ui.ChatActivity"
android:exported="false"
android:screenOrientation="unspecified"
android:windowSoftInputMode="adjustResize" />
<service
android:name=".service.KazeiaService"
android:foregroundServiceType="microphone|specialUse"
android:exported="true">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="AI inference service for emotional support chatbot" />
</service>
</application>
</manifest>

View File

@ -0,0 +1,88 @@
{
"commands": [
{
"action": "STOP_LISTENING",
"triggers": [
"système stop recording",
"système stop récording",
"système stop récordi",
"système stop listening",
"système arrête enregistrement",
"système stop",
"system stop recording",
"system stop"
],
"description": "Arrête le mode écoute continue"
},
{
"action": "DEBUG_START",
"triggers": [
"système debug start",
"système de bug start",
"système de bugstart",
"système des bugs start",
"système console start",
"système console on",
"system debug start",
"system console start"
],
"description": "Affiche le panneau de debug (logs + stats)"
},
{
"action": "DEBUG_STOP",
"triggers": [
"système debug stop",
"système de bug stop",
"système des bugs stop",
"système console stop",
"système console off",
"system debug stop",
"system console stop"
],
"description": "Masque le panneau de debug"
},
{
"action": "CLEAR_CHAT",
"triggers": [
"système clear chat",
"système efface conversation",
"système nouvelle conversation",
"system clear chat"
],
"description": "Efface l'historique du chat"
},
{
"action": "REPEAT",
"triggers": [
"système repeat",
"système répète",
"système redis",
"system repeat"
],
"description": "Répète la dernière réponse"
},
{
"action": "STATUS",
"triggers": [
"système status",
"système statut",
"système état",
"system status"
],
"description": "Affiche l'état du système"
},
{
"action": "LIST_COMMANDS",
"triggers": [
"système liste commande",
"système liste commandes",
"système list command",
"système aide",
"système help",
"system list command",
"system help"
],
"description": "Affiche la liste des commandes disponibles"
}
]
}

View File

@ -0,0 +1,37 @@
package com.kazeia
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
class KazeiaApplication : Application() {
companion object {
const val CHANNEL_ID = "kazeia_service_channel"
const val MODELS_DIR = "/data/local/tmp/kazeia/models"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
// Note: Unity native lib preloading was removed because Unity 6 GameActivity
// requires its own initialization sequence. Loading libs out of order causes
// native crashes. Unity handles lib loading internally in onCreate().
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_text)
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
}

View File

@ -0,0 +1,113 @@
package com.kazeia.audio
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import com.kazeia.core.VadEngine
import kotlin.concurrent.thread
class AudioCaptureManager(
private val sampleRate: Int = 16000
) {
companion object {
private const val TAG = "AudioCapture"
}
private var audioRecord: AudioRecord? = null
private var isRunning = false
private var listenerThread: Thread? = null
@SuppressLint("MissingPermission")
fun start(
vad: VadEngine,
silenceDurationMs: Int = 800,
speechMinDurationMs: Int = 150,
onSpeechSegment: (ShortArray) -> Unit
) {
Log.i(TAG, "Starting audio capture with VAD")
val frameSize = 512 // 32ms at 16kHz
val frameDurationMs = (frameSize.toFloat() / sampleRate * 1000).toInt()
val bufferSize = maxOf(
AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
),
sampleRate * 2
)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize
).also { it.startRecording() }
isRunning = true
listenerThread = thread(name = "AudioCapture-VAD") {
val frame = ShortArray(frameSize)
val speechBuffer = mutableListOf<ShortArray>()
var speechFrameCount = 0
var silenceFrameCount = 0
var isSpeechActive = false
val silenceFramesNeeded = silenceDurationMs / frameDurationMs
val speechFramesNeeded = speechMinDurationMs / frameDurationMs
while (isRunning) {
val read = audioRecord?.read(frame, 0, frameSize) ?: 0
if (read != frameSize) continue
val isSpeech = try {
vad.isSpeech(frame)
} catch (e: Exception) {
Log.e(TAG, "VAD error", e)
false
}
if (isSpeech) {
silenceFrameCount = 0
speechFrameCount++
speechBuffer.add(frame.copyOf())
if (speechFrameCount >= speechFramesNeeded && !isSpeechActive) {
isSpeechActive = true
}
} else {
if (isSpeechActive) {
silenceFrameCount++
speechBuffer.add(frame.copyOf())
if (silenceFrameCount >= silenceFramesNeeded) {
val fullAudio = speechBuffer.flatMap { it.toList() }.toShortArray()
Log.i(TAG, "Speech segment: ${fullAudio.size} samples")
onSpeechSegment(fullAudio)
speechBuffer.clear()
speechFrameCount = 0
silenceFrameCount = 0
isSpeechActive = false
}
} else {
speechBuffer.clear()
speechFrameCount = 0
}
}
}
}
}
fun stop() {
isRunning = false
listenerThread?.join(1000)
listenerThread = null
audioRecord?.stop()
audioRecord?.release()
audioRecord = null
}
}

View File

@ -0,0 +1,54 @@
package com.kazeia.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
class AudioPlaybackManager(
private val sampleRate: Int = 24000
) {
private var audioTrack: AudioTrack? = null
fun play(audioData: ShortArray, sampleRate: Int = this.sampleRate, onComplete: (() -> Unit)? = null) {
stop()
val bufferSize = audioData.size * 2
audioTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STATIC)
.build()
audioTrack?.apply {
write(audioData, 0, audioData.size)
setNotificationMarkerPosition(audioData.size)
setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
override fun onMarkerReached(track: AudioTrack?) {
onComplete?.invoke()
}
override fun onPeriodicNotification(track: AudioTrack?) {}
})
play()
}
}
fun stop() {
audioTrack?.apply {
try { stop() } catch (_: Exception) {}
release()
}
audioTrack = null
}
}

View File

@ -0,0 +1,21 @@
package com.kazeia.conversation
class ConversationManager {
private var turnCount = 0
fun onNewTurn() {
turnCount++
}
fun currentTemperature(): Float {
return when {
turnCount < 3 -> 0.6f
turnCount < 10 -> 0.7f
else -> 0.75f
}
}
fun reset() {
turnCount = 0
}
}

View File

@ -0,0 +1,19 @@
package com.kazeia.conversation
import com.kazeia.core.*
/**
* Simple echo processor repeats input as output.
* Used as fallback when no LLM is available, or for testing TTS.
*/
class EchoProcessor : MessageProcessor {
override val name = "Echo"
override suspend fun initialize() {}
override fun isReady(): Boolean = true
override suspend fun process(input: String, context: ConversationContext): ProcessorResult {
return ProcessorResult(responseText = input)
}
override fun release() {}
}

View File

@ -0,0 +1,58 @@
package com.kazeia.conversation
import android.util.Log
import com.kazeia.core.*
/**
* LLM-based message processor.
* Wraps any LlmEngine implementation (ExecuTorch, Genie, llama.cpp, etc.)
*/
class LlmProcessor(
private val llmEngine: LlmEngine,
private val modelPath: String,
private val config: LlmConfig = LlmConfig()
) : MessageProcessor {
override val name = "LLM"
private val promptBuilder = PromptBuilder()
private val stoppingCriteria = StoppingCriteria()
override suspend fun initialize() {
llmEngine.load(modelPath, config)
}
override fun isReady(): Boolean = llmEngine.isLoaded()
override suspend fun process(input: String, context: ConversationContext): ProcessorResult {
if (!isReady()) {
return ProcessorResult(
responseText = input, // echo mode
metadata = mapOf("mode" to "echo")
)
}
stoppingCriteria.reset()
val prompt = promptBuilder.build(input, context.history)
val result = llmEngine.generate(
prompt = prompt,
params = SamplingParams(maxNewTokens = 120, temperature = 0.7f),
onToken = { token ->
!stoppingCriteria.shouldStop(token)
}
)
return ProcessorResult(
responseText = result.text.trim(),
metadata = mapOf(
"tokens" to result.tokenCount,
"tok_per_sec" to result.tokensPerSecond,
"latency_ms" to result.timeMs
)
)
}
override fun release() {
llmEngine.release()
}
}

View File

@ -0,0 +1,12 @@
package com.kazeia.conversation
import com.kazeia.core.ChatMessage
class PromptBuilder {
fun build(
message: String,
history: List<ChatMessage>,
maxHistoryTurns: Int = 3
): String = message
}

View File

@ -0,0 +1,26 @@
package com.kazeia.conversation
class StoppingCriteria(
private val maxSentences: Int = 3,
private val stopAfterQuestion: Boolean = true,
private val maxTokens: Int = 120
) {
private var tokenCount = 0
fun shouldStop(generatedText: String): Boolean {
tokenCount++
if (tokenCount >= maxTokens) return true
val sentenceEnders = generatedText.count { it == '.' || it == '!' || it == '?' }
if (sentenceEnders >= maxSentences) return true
if (stopAfterQuestion && generatedText.contains('?') && tokenCount > 15) {
return true
}
return false
}
fun reset() { tokenCount = 0 }
}

View File

@ -0,0 +1,111 @@
package com.kazeia.conversation
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.text.Normalizer
data class VoiceCommand(
val action: String,
val triggers: List<String>,
val description: String,
val extractParam: Boolean = false
)
data class CommandMatch(
val command: VoiceCommand,
val param: String? = null
)
class VoiceCommandProcessor(context: Context) {
companion object {
private const val TAG = "VoiceCmd"
}
private val commands = mutableListOf<VoiceCommand>()
init {
loadCommands(context)
}
private fun loadCommands(context: Context) {
try {
val json = context.assets.open("voice_commands.json").bufferedReader().readText()
val root = JSONObject(json)
val arr = root.getJSONArray("commands")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
val triggers = mutableListOf<String>()
val trigArr = obj.getJSONArray("triggers")
for (j in 0 until trigArr.length()) {
triggers.add(trigArr.getString(j))
}
commands.add(VoiceCommand(
action = obj.getString("action"),
triggers = triggers,
description = obj.optString("description", ""),
extractParam = obj.optBoolean("extract_param", false)
))
}
Log.i(TAG, "Loaded ${commands.size} voice commands")
} catch (e: Exception) {
Log.e(TAG, "Failed to load voice commands", e)
}
}
/**
* Check if the transcribed text matches any voice command.
* Returns the matched command or null.
*/
fun match(text: String): CommandMatch? {
val normalized = normalize(text)
// Sort commands by longest trigger first to avoid partial matches
for (cmd in commands) {
for (trigger in cmd.triggers.sortedByDescending { it.length }) {
val normalizedTrigger = normalize(trigger)
// Exact match or starts-with
if (normalized == normalizedTrigger || normalized.startsWith("$normalizedTrigger ")) {
val param = extractParam(cmd, normalized, normalizedTrigger, text, trigger)
Log.i(TAG, "Matched: '${cmd.action}' trigger='$trigger' param=$param")
return CommandMatch(cmd, param)
}
// Contains match: the trigger appears anywhere in the text
// Useful because Whisper may prepend/append words
if (normalized.contains(normalizedTrigger)) {
Log.i(TAG, "Contains match: '${cmd.action}' trigger='$trigger' in '$normalized'")
return CommandMatch(cmd, null)
}
}
}
return null
}
private fun extractParam(
cmd: VoiceCommand, normalized: String, normalizedTrigger: String,
text: String, trigger: String
): String? {
if (!cmd.extractParam || normalized.length <= normalizedTrigger.length) return null
val idx = text.lowercase().indexOf(trigger.lowercase())
return if (idx >= 0) text.substring(idx + trigger.length).trim() else null
}
/**
* Normalize text for comparison: lowercase, remove accents, trim
*/
private fun normalize(text: String): String {
val lower = text.lowercase().trim()
// Remove accents
val decomposed = Normalizer.normalize(lower, Normalizer.Form.NFD)
return decomposed.replace(Regex("[\\p{InCombiningDiacriticalMarks}]"), "")
.replace(Regex("[^a-z0-9 ]"), "")
.replace(Regex("\\s+"), " ")
.trim()
}
fun getCommands(): List<VoiceCommand> = commands.toList()
}

View File

@ -0,0 +1,34 @@
package com.kazeia.conversation
import android.content.Context
import com.kazeia.core.*
/**
* Voice command processor intercepts commands before they reach the LLM.
* Returns shouldContinueChain=true if no command matched (pass to next processor).
*/
class VoiceCommandProcessor2(context: Context) : MessageProcessor {
override val name = "VoiceCommands"
private val cmdProcessor = VoiceCommandProcessor(context)
override suspend fun initialize() {}
override fun isReady(): Boolean = true
override suspend fun process(input: String, context: ConversationContext): ProcessorResult {
val match = cmdProcessor.match(input) ?: return ProcessorResult(
responseText = "",
shouldContinueChain = true // no command → pass to next processor
)
// Command matched
return ProcessorResult(
responseText = "[Commande] ${match.command.description}",
shouldSpeak = false,
shouldContinueChain = false,
metadata = mapOf("command" to match.command.action, "param" to (match.param ?: ""))
)
}
override fun release() {}
}

View File

@ -0,0 +1,23 @@
package com.kazeia.core
sealed class PipelineState {
object Idle : PipelineState()
object Listening : PipelineState()
object SpeechDetected : PipelineState()
object Transcribing : PipelineState()
data class Transcribed(val text: String) : PipelineState()
object Thinking : PipelineState()
data class TokenGenerated(val token: String, val fullText: String) : PipelineState()
data class ResponseReady(val text: String) : PipelineState()
object Speaking : PipelineState()
data class Error(val message: String) : PipelineState()
}
data class ChatMessage(
val id: Long = System.currentTimeMillis(),
val role: Role,
val text: String,
val timestamp: Long = System.currentTimeMillis()
) {
enum class Role { PATIENT, KAZEIA, SYSTEM }
}

View File

@ -0,0 +1,33 @@
package com.kazeia.core
interface LlmEngine {
suspend fun load(modelPath: String, config: LlmConfig)
fun isLoaded(): Boolean
suspend fun generate(
prompt: String,
params: SamplingParams = SamplingParams(),
onToken: ((String) -> Boolean)? = null
): GenerationResult
fun release()
}
data class LlmConfig(
val backend: String = "npu",
val maxContextLength: Int = 4096,
val kvCacheQuantization: String = "int8"
)
data class SamplingParams(
val maxNewTokens: Int = 120,
val temperature: Float = 0.7f,
val topP: Float = 0.85f,
val topK: Int = 40,
val repetitionPenalty: Float = 1.2f
)
data class GenerationResult(
val text: String,
val tokenCount: Int,
val timeMs: Long,
val tokensPerSecond: Float
)

View File

@ -0,0 +1,62 @@
package com.kazeia.core
/**
* Conversation context shared across all processors.
*/
data class ConversationContext(
val history: List<ChatMessage> = emptyList(),
val metadata: MutableMap<String, Any> = mutableMapOf(),
val language: String = "fr",
val speakerId: String? = null, // for diarization
val emotion: String? = null, // detected emotion
val sessionId: String = System.currentTimeMillis().toString()
)
/**
* Result from a message processor.
*/
data class ProcessorResult(
val responseText: String,
val shouldSpeak: Boolean = true,
val shouldContinueChain: Boolean = false, // true = pass to next processor
val metadata: Map<String, Any> = emptyMap()
)
/**
* Pluggable message processor interface.
* Implementations: LLM, RAG, emotion detection, rules engine, etc.
*/
interface MessageProcessor {
val name: String
suspend fun initialize()
fun isReady(): Boolean
/**
* Process an input message and return a result.
* @param input transcribed text from STT
* @param context conversation context (history, metadata)
* @return processed result with response text
*/
suspend fun process(input: String, context: ConversationContext): ProcessorResult
fun release()
}
/**
* Pipeline manager that orchestrates STT [Processors] TTS.
* STT and TTS are independent they only exchange text with the processors.
*/
interface PipelineOrchestrator {
fun setSttEngine(engine: SttEngine)
fun setTtsEngine(engine: TtsEngine)
fun addProcessor(processor: MessageProcessor)
fun removeProcessor(name: String)
fun getProcessors(): List<MessageProcessor>
suspend fun processTextInput(text: String)
suspend fun processSpeechInput(audioData: ShortArray)
fun startListening()
fun stopListening()
}

View File

@ -0,0 +1,18 @@
package com.kazeia.core
interface SttEngine {
suspend fun load(modelPath: String? = null)
fun isLoaded(): Boolean
suspend fun transcribe(
audioData: ShortArray,
language: String = "fr"
): TranscriptionResult
fun release()
}
data class TranscriptionResult(
val text: String,
val confidence: Float,
val language: String,
val durationMs: Long
)

View File

@ -0,0 +1,24 @@
package com.kazeia.core
interface TtsEngine {
suspend fun load(modelPath: String? = null, voiceId: String? = null)
fun isLoaded(): Boolean
suspend fun synthesize(
text: String,
language: String = "fr"
): TtsResult
suspend fun synthesizeAndPlay(
text: String,
language: String = "fr",
onStart: (() -> Unit)? = null,
onComplete: (() -> Unit)? = null
)
fun stop()
fun release()
}
data class TtsResult(
val audioData: ShortArray,
val sampleRate: Int = 24000,
val durationMs: Long
)

View File

@ -0,0 +1,11 @@
package com.kazeia.core
import android.content.Context
interface VadEngine {
fun load(context: Context)
fun isLoaded(): Boolean
fun isSpeech(frame: ShortArray): Boolean
fun resetState()
fun release()
}

View File

@ -0,0 +1,228 @@
package com.kazeia.llm
import android.util.Log
import com.kazeia.core.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* LLM Engine using ExecuTorch + QNN backend via subprocess.
* Calls qnn_llama_runner binary with root access.
* Qwen3-0.6B at ~90 tok/s on NPU (Snapdragon 8 Elite).
*/
class ExecuTorchLlmEngine(
private val onLog: ((String) -> Unit)? = null
) : LlmEngine {
companion object {
private const val TAG = "ExecuTorchLLM"
private const val RUNNER_DIR = "/data/local/tmp/kazeia-et"
private const val SYSTEM_PROMPT = ""
}
private var modelName = ""
private var loaded = false
private fun nlog(msg: String) {
Log.i(TAG, msg)
onLog?.invoke("[LLM] $msg")
}
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
val check = execRoot("ls $RUNNER_DIR/qnn_llama_runner $RUNNER_DIR/hybrid_llama_qnn.pte $RUNNER_DIR/tokenizer.json 2>&1")
if (check.contains("No such file")) {
nlog("ERROR: runner or model not found in $RUNNER_DIR")
return@withContext
}
// Deploy runner script
deployRunnerScript()
// Quick test
writeFileRoot("$RUNNER_DIR/outputs/prompt.b64",
android.util.Base64.encodeToString("Bonjour".toByteArray(), android.util.Base64.NO_WRAP))
execRoot("rm -f $RUNNER_DIR/outputs/system.b64")
val test = execRoot("su -c 'sh $RUNNER_DIR/run_llm.sh 0.0 80 2>&1'")
if (test.contains("Generated Tokens") || test.contains("Rate:")) {
loaded = true
val rateMatch = Regex("Generated \\d+ tokens:.*Rate:\\s+([\\d.]+)").find(test)
val rate = rateMatch?.groupValues?.get(1) ?: "?"
modelName = "Qwen3 (${rate} tok/s NPU)"
nlog("Ready: $modelName")
} else {
nlog("ERROR: test failed: ${test.takeLast(200)}")
}
}
}
override fun isLoaded(): Boolean = loaded
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = withContext(Dispatchers.IO) {
if (!loaded) throw IllegalStateException("Model not loaded")
val startTime = System.currentTimeMillis()
// Write base64-encoded prompt to file (avoids all shell escaping issues)
writeFileRoot("$RUNNER_DIR/outputs/prompt.b64",
android.util.Base64.encodeToString(prompt.toByteArray(), android.util.Base64.NO_WRAP))
if (SYSTEM_PROMPT.isNotEmpty()) {
writeFileRoot("$RUNNER_DIR/outputs/system.b64",
android.util.Base64.encodeToString(SYSTEM_PROMPT.toByteArray(), android.util.Base64.NO_WRAP))
} else {
execRoot("rm -f $RUNNER_DIR/outputs/system.b64")
}
nlog("Prompt: '${prompt.take(80)}'")
// seq_len = maxNewTokens but capped at model's compiled max context (512)
val seqLen = minOf(params.maxNewTokens, 512)
val output = execRoot("su -c 'sh $RUNNER_DIR/run_llm.sh ${params.temperature} $seqLen 2>&1'")
// Parse perf stats
val tokenCount = Regex("Generated Tokens:\\s+(\\d+)").find(output)
?.groupValues?.get(1)?.toIntOrNull() ?: 0
val rate = Regex("Generated \\d+ tokens:.*Rate:\\s+([\\d.]+)").find(output)
?.groupValues?.get(1)?.toFloatOrNull() ?: 0f
val ttft = Regex("Time to first generated token:\\s+([\\d.]+)").find(output)
?.groupValues?.get(1)?.toFloatOrNull() ?: 0f
// Read response
val responseRaw = execRoot("cat $RUNNER_DIR/outputs/response.txt 2>/dev/null")
nlog("RAW: ${responseRaw.take(300)}")
val responseText = extractResponse(responseRaw)
val elapsed = System.currentTimeMillis() - startTime
nlog("Response: '$responseText'")
nlog("Stats: ${tokenCount}tok ${rate}tok/s TTFT=${ttft}s ${elapsed}ms")
onToken?.invoke(responseText)
GenerationResult(
text = responseText,
tokenCount = tokenCount,
timeMs = elapsed,
tokensPerSecond = rate
)
}
/** Extract clean response text from Qwen3 output (strips think block and special tokens) */
private fun extractResponse(raw: String): String {
var text = raw
// Strip everything up to and including </think>
val thinkEnd = text.indexOf("</think>")
if (thinkEnd >= 0) {
text = text.substring(thinkEnd + "</think>".length)
} else {
// No </think> found — the think block consumed all tokens
// Try to find any text after the <think> block that looks like a response
val thinkStart = text.indexOf("<think>")
val assistantTag = text.indexOf("assistant")
if (thinkStart >= 0) {
// Think block never closed — no usable response
// Return empty so the service can handle it
nlog("WARN: <think> block never closed, no response generated")
return ""
} else if (assistantTag >= 0) {
text = text.substring(assistantTag + "assistant".length)
}
}
return text
.replace("<|im_start|>", "")
.replace("<|im_end|>", "")
.replace("<|endoftext|>", "")
.replace("<think>", "")
.replace("</think>", "")
.trim()
}
/** Deploy a shell script that decodes base64 prompt to avoid all shell escaping issues */
private fun deployRunnerScript() {
val script = """
#!/bin/sh
cd $RUNNER_DIR
export LD_LIBRARY_PATH=$RUNNER_DIR
export ADSP_LIBRARY_PATH=$RUNNER_DIR
TEMP=${'$'}1
SEQ_LEN=${'$'}2
# Decode base64 prompt (avoids all shell escaping issues with quotes/apostrophes)
PROMPT=${'$'}(base64 -d $RUNNER_DIR/outputs/prompt.b64)
# Clear old response
rm -f $RUNNER_DIR/outputs/response.txt
SYSTEM_ARGS=""
if [ -s $RUNNER_DIR/outputs/system.b64 ]; then
SYSTEM=${'$'}(base64 -d $RUNNER_DIR/outputs/system.b64)
SYSTEM_ARGS="--system_prompt"
fi
if [ -n "${'$'}SYSTEM_ARGS" ]; then
exec ./qnn_llama_runner \
--model_path hybrid_llama_qnn.pte \
--tokenizer_path tokenizer.json \
--decoder_model_version qwen3 \
--output_path $RUNNER_DIR/outputs/response.txt \
--performance_output_path $RUNNER_DIR/outputs/perf.txt \
--shared_buffer \
--system_prompt "${'$'}SYSTEM" \
--prompt "${'$'}PROMPT" \
--temperature ${'$'}TEMP \
--seq_len ${'$'}SEQ_LEN \
--eval_mode 1
else
exec ./qnn_llama_runner \
--model_path hybrid_llama_qnn.pte \
--tokenizer_path tokenizer.json \
--decoder_model_version qwen3 \
--output_path $RUNNER_DIR/outputs/response.txt \
--performance_output_path $RUNNER_DIR/outputs/perf.txt \
--shared_buffer \
--prompt "${'$'}PROMPT" \
--temperature ${'$'}TEMP \
--seq_len ${'$'}SEQ_LEN \
--eval_mode 1
fi
""".trimIndent()
writeFileRoot("$RUNNER_DIR/run_llm.sh", script)
execRoot("chmod 755 $RUNNER_DIR/run_llm.sh")
}
override fun release() {
loaded = false
}
private fun writeFileRoot(path: String, content: String) {
try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat > $path"))
process.outputStream.bufferedWriter().use { it.write(content) }
process.waitFor()
} catch (e: Exception) {
Log.e(TAG, "writeFileRoot failed: ${e.message}")
}
}
private fun execRoot(cmd: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
val result = process.inputStream.bufferedReader().readText()
val error = process.errorStream.bufferedReader().readText()
process.waitFor()
if (error.isNotEmpty() && result.isEmpty()) error else result
} catch (e: Exception) {
Log.e(TAG, "execRoot failed: ${e.message}")
""
}
}
}

View File

@ -0,0 +1,53 @@
package com.kazeia.llm
/**
* JNI bridge to Qualcomm Genie SDK (libGenie.so).
* Native implementation in jni/genie_jni.cpp
*/
object GenieJni {
init {
System.loadLibrary("Genie")
System.loadLibrary("genie_jni")
}
/**
* Initialize Genie dialog from a JSON config file.
* @param configPath path to genie_config.json
* @return handle (pointer) to the dialog, or 0 on failure
*/
external fun createDialog(configPath: String): Long
/**
* Send a query to the dialog and get a response.
* @param dialogHandle handle from createDialog
* @param prompt the text prompt
* @param callback called with each decoded token; return false to stop
* @return full response string
*/
external fun query(dialogHandle: Long, prompt: String, callback: TokenCallback?): String
/**
* Set a stop sequence for the dialog.
* @param dialogHandle handle from createDialog
* @param stopSequence the stop sequence string
*/
external fun setStopSequence(dialogHandle: Long, stopSequence: String)
/**
* Free the dialog resources.
* @param dialogHandle handle from createDialog
*/
external fun freeDialog(dialogHandle: Long)
/**
* Get Genie API version.
* @return "major.minor"
*/
external fun getVersion(): String
interface TokenCallback {
/** Called for each generated token. Return false to stop generation. */
fun onToken(token: String): Boolean
}
}

View File

@ -0,0 +1,74 @@
package com.kazeia.llm
import android.util.Log
import com.kazeia.core.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GenieLlmEngine : LlmEngine {
companion object {
private const val TAG = "GenieLlmEngine"
}
private var dialogHandle: Long = 0
private var loaded = false
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
Log.i(TAG, "Loading Genie model from $modelPath")
val configFile = "$modelPath/genie_config.json"
dialogHandle = GenieJni.createDialog(configFile)
if (dialogHandle == 0L) {
throw RuntimeException("Failed to create Genie dialog from $configFile")
}
// Set stop sequences for chat
GenieJni.setStopSequence(dialogHandle, "Patient:")
GenieJni.setStopSequence(dialogHandle, "\nPatient")
loaded = true
Log.i(TAG, "Genie model loaded, handle=$dialogHandle, version=${GenieJni.getVersion()}")
}
}
override fun isLoaded(): Boolean = loaded
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = withContext(Dispatchers.IO) {
if (!loaded) throw IllegalStateException("Model not loaded")
val startTime = System.currentTimeMillis()
var tokenCount = 0
val callback = if (onToken != null) {
object : GenieJni.TokenCallback {
override fun onToken(token: String): Boolean {
tokenCount++
return onToken(token)
}
}
} else null
val response = GenieJni.query(dialogHandle, prompt, callback)
val elapsed = System.currentTimeMillis() - startTime
if (tokenCount == 0) tokenCount = response.split(" ").size
GenerationResult(
text = response,
tokenCount = tokenCount,
timeMs = elapsed,
tokensPerSecond = if (elapsed > 0) tokenCount * 1000f / elapsed else 0f
)
}
override fun release() {
if (dialogHandle != 0L) {
GenieJni.freeDialog(dialogHandle)
dialogHandle = 0
loaded = false
Log.i(TAG, "Genie model released")
}
}
}

View File

@ -0,0 +1,161 @@
package com.kazeia.service
import android.util.Log
import com.kazeia.core.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Orchestrates the full pipeline: STT [Processors chain] TTS
* STT and TTS are independent they only exchange text.
* Processors are pluggable and executed in order.
*/
class KazeiaPipeline {
companion object {
private const val TAG = "Pipeline"
}
private var stt: SttEngine? = null
private var tts: TtsEngine? = null
private val processors = mutableListOf<MessageProcessor>()
private val context = ConversationContext()
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
private val _logs = MutableStateFlow<List<String>>(emptyList())
val logs: StateFlow<List<String>> = _logs
private val _pipelineState = MutableStateFlow<PipelineState>(PipelineState.Idle)
val pipelineState: StateFlow<PipelineState> = _pipelineState
fun setStt(engine: SttEngine) { stt = engine; log("STT set: ${engine::class.simpleName}") }
fun setTts(engine: TtsEngine) { tts = engine; log("TTS set: ${engine::class.simpleName}") }
fun addProcessor(processor: MessageProcessor) {
processors.add(processor)
log("Processor added: ${processor.name} (${processors.size} total)")
}
fun removeProcessor(name: String) {
processors.removeAll { it.name == name }
log("Processor removed: $name")
}
fun getProcessors(): List<MessageProcessor> = processors.toList()
/**
* Process text input through the pipeline: [Processors] TTS
*/
suspend fun processText(text: String) {
log("Input: '$text'")
addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = text))
context.metadata["last_input"] = text
val t0 = System.currentTimeMillis()
val result = runProcessors(text)
val processingMs = System.currentTimeMillis() - t0
if (result.responseText.isNotBlank()) {
log("Response: '${result.responseText.take(60)}...' (${processingMs}ms)")
addMessage(ChatMessage(role = ChatMessage.Role.KAZEIA, text = result.responseText))
// Log metadata
result.metadata.forEach { (k, v) -> log(" $k=$v") }
// TTS
if (result.shouldSpeak) {
speak(result.responseText)
}
}
// Update context history
context.history.toMutableList().apply {
add(ChatMessage(role = ChatMessage.Role.PATIENT, text = text))
if (result.responseText.isNotBlank()) {
add(ChatMessage(role = ChatMessage.Role.KAZEIA, text = result.responseText))
}
}
}
/**
* Process audio through: STT [Processors] TTS
*/
suspend fun processAudio(audioData: ShortArray) {
val sttEngine = stt ?: return
_pipelineState.value = PipelineState.Transcribing
val t0 = System.currentTimeMillis()
val transcription = sttEngine.transcribe(audioData, context.language)
val sttMs = System.currentTimeMillis() - t0
if (transcription.text.isBlank()) {
log("STT: (silence) ${sttMs}ms")
return
}
log("STT: '${transcription.text}' ${sttMs}ms (RTF=${"%.2f".format(sttMs.toFloat() / (audioData.size * 1000f / 16000))})")
_pipelineState.value = PipelineState.Transcribed(transcription.text)
processText(transcription.text)
}
/**
* Run text through all processors in chain.
* First processor that returns shouldContinueChain=false wins.
*/
private suspend fun runProcessors(text: String): ProcessorResult {
_pipelineState.value = PipelineState.Thinking
for (processor in processors) {
if (!processor.isReady()) continue
try {
val t0 = System.currentTimeMillis()
val result = processor.process(text, context)
val elapsed = System.currentTimeMillis() - t0
log("[${processor.name}] ${elapsed}ms → ${if (result.shouldContinueChain) "continue" else "done"}")
if (!result.shouldContinueChain) {
return result
}
} catch (e: Exception) {
log("[${processor.name}] ERROR: ${e.message}")
}
}
// No processor handled it → echo
return ProcessorResult(responseText = text, metadata = mapOf("mode" to "echo"))
}
private suspend fun speak(text: String) {
val ttsEngine = tts ?: return
_pipelineState.value = PipelineState.Speaking
try {
ttsEngine.synthesizeAndPlay(text, context.language,
onComplete = { _pipelineState.value = PipelineState.Idle }
)
} catch (e: Exception) {
log("TTS error: ${e.message}")
}
_pipelineState.value = PipelineState.Idle
}
fun addMessage(msg: ChatMessage) {
_messages.value = _messages.value + msg
}
fun log(msg: String) {
Log.i(TAG, msg)
val time = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.FRANCE)
.format(java.util.Date())
_logs.value = _logs.value.takeLast(199) + "$time $msg"
}
fun release() {
stt?.release()
tts?.release()
processors.forEach { it.release() }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
package com.kazeia.stt
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import com.kazeia.core.SttEngine
import com.kazeia.core.TranscriptionResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
class AndroidSttEngine(private val context: Context) : SttEngine {
companion object {
private const val TAG = "AndroidSttEngine"
}
private var available = false
private var recognizer: SpeechRecognizer? = null
override suspend fun load(modelPath: String?) {
withContext(Dispatchers.Main) {
available = SpeechRecognizer.isRecognitionAvailable(context)
Log.i(TAG, "Android SpeechRecognizer available: $available")
}
}
override fun isLoaded(): Boolean = available
/**
* Transcribe raw audio data not supported by Android SpeechRecognizer.
* Falls back to empty result. Use listenAndTranscribe() instead.
*/
override suspend fun transcribe(
audioData: ShortArray,
language: String
): TranscriptionResult {
return TranscriptionResult(text = "", confidence = 0f, language = language, durationMs = 0)
}
/**
* Listen from microphone and transcribe using Android SpeechRecognizer.
* This is the main method for STT it handles the mic internally.
*/
suspend fun listenAndTranscribe(language: String = "fr"): TranscriptionResult =
withContext(Dispatchers.Main) {
if (!available) {
return@withContext TranscriptionResult(
text = "", confidence = 0f, language = language, durationMs = 0
)
}
suspendCancellableCoroutine { continuation ->
val startTime = System.currentTimeMillis()
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(context)
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, language)
putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, language)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
}
recognizer?.setRecognitionListener(object : RecognitionListener {
override fun onResults(results: Bundle?) {
val elapsed = System.currentTimeMillis() - startTime
val matches = results?.getStringArrayList(
SpeechRecognizer.RESULTS_RECOGNITION
)
val confidence = results?.getFloatArray(
SpeechRecognizer.CONFIDENCE_SCORES
)
val text = matches?.firstOrNull() ?: ""
Log.i(TAG, "Transcription: \"$text\" (${elapsed}ms)")
if (continuation.isActive) {
continuation.resume(
TranscriptionResult(
text = text,
confidence = confidence?.firstOrNull() ?: 0f,
language = language,
durationMs = elapsed
)
)
}
}
override fun onError(error: Int) {
val errorMsg = when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
SpeechRecognizer.ERROR_CLIENT -> "Client error"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "No match"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
else -> "Unknown ($error)"
}
Log.e(TAG, "Recognition error: $errorMsg")
if (continuation.isActive) {
continuation.resume(
TranscriptionResult(
text = "",
confidence = 0f,
language = language,
durationMs = 0
)
)
}
}
override fun onReadyForSpeech(params: Bundle?) {
Log.i(TAG, "Ready for speech")
}
override fun onBeginningOfSpeech() {
Log.i(TAG, "Speech started")
}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
Log.i(TAG, "Speech ended")
}
override fun onPartialResults(partialResults: Bundle?) {}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
recognizer?.startListening(intent)
continuation.invokeOnCancellation {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
}
}
fun stopListening() {
recognizer?.stopListening()
}
override fun release() {
recognizer?.destroy()
recognizer = null
available = false
}
}

View File

@ -0,0 +1,18 @@
package com.kazeia.stt
/**
* HuggingFace-compatible Whisper mel spectrogram extractor in native C++.
* Exact replica of WhisperFeatureExtractor: STFT + mel filters + log10 + normalize.
* No dependency on whisper.cpp.
*/
object MelExtractor {
init {
System.loadLibrary("mel_extractor")
}
/** Load mel filter bank [N_MELS * (N_FFT/2+1)] = [80 * 201] floats */
external fun loadFilters(filters: FloatArray)
/** Compute mel spectrogram from PCM16 audio. Returns float[80*3000] or null. */
external fun computeMel(audioData: ShortArray): FloatArray?
}

View File

@ -0,0 +1,542 @@
package com.kazeia.stt
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OnnxJavaType
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.util.Log
import com.kazeia.core.SttEngine
import com.kazeia.core.TranscriptionResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import java.nio.IntBuffer
import java.nio.LongBuffer
/**
* Whisper STT with Qualcomm HfWhisper KV-cache models on NPU.
*
* Supports both Whisper-Base (6 layers, 8 heads) and Whisper-Small (12 layers, 12 heads).
* Model dimensions are auto-detected from the ONNX session at load time.
*
* Architecture (Qualcomm AI Hub reference):
* Encoder: input_features [1,80,3000] fp16 N cross KV caches fp16
* Decoder: input_ids [1,1] int32 + attention_mask [1,1,1,200] fp16
* + N self KV caches (199 slots) fp16 + N cross KV caches fp16
* + position_ids [1] int32
* logits [1,51865,1,1] fp16 + N updated self KV caches fp16
*
* Fallback: standard ONNX encoder/decoder on CPU, then whisper.cpp CPU.
*/
class WhisperHybridEngine(
private val nativeLibDir: String,
private val onLog: ((String) -> Unit)? = null
) : SttEngine {
companion object {
private const val TAG = "WhisperNPU"
private const val SOT = 50258 // decoder_start_token_id
private const val EOT = 50257 // eos_token_id
private const val TRANSLATE = 50358 // <|translate|> — we override this
private const val TRANSCRIBE = 50359 // <|transcribe|> — always use this
private const val VOCAB_SIZE = 51865
private const val MEAN_DECODE_LEN = 200
private const val HEAD_DIM = 64 // always 64 for Whisper (Base/Small/Medium)
private const val MASK_NEG = -100.0f
}
private var whisperCtx: Long = 0
private var ortEnv: OrtEnvironment? = null
// Qualcomm HfWhisper KV-cache models (NPU)
private var hfEncoderSession: OrtSession? = null
private var hfDecoderSession: OrtSession? = null
// Standard ONNX models (CPU fallback)
private var encoderSession: OrtSession? = null
private var decoderSession: OrtSession? = null
private var vocab: Map<Int, String> = emptyMap()
private var loaded = false
private var modelPath: String? = null
private var useHfModels = false
private val logFile = File("/data/local/tmp/kazeia/whisper_npu.log")
// Auto-detected model dimensions
private var numDecoderLayers = 0
private var numDecoderHeads = 0
private fun nlog(msg: String) {
Log.i(TAG, msg)
onLog?.invoke("[STT] $msg")
try { logFile.appendText("${System.currentTimeMillis()} $msg\n") } catch (_: Exception) {}
}
override suspend fun load(modelPath: String?) {
withContext(Dispatchers.IO) {
val path = modelPath ?: return@withContext
this@WhisperHybridEngine.modelPath = path
try {
// Load mel filters for HuggingFace-compatible mel extraction
val melFiltersFile = File("$path/mel_filters.json")
if (melFiltersFile.exists()) {
val jsonArray = org.json.JSONArray(melFiltersFile.readText())
val filters = FloatArray(jsonArray.length()) { jsonArray.getDouble(it).toFloat() }
MelExtractor.loadFilters(filters)
nlog("Mel filters loaded: ${filters.size} values")
} else {
nlog("WARN: mel_filters.json not found at $path")
}
// whisper.cpp as fallback only
val ggmlPath = "/data/local/tmp/kazeia/models/whisper-base/ggml-base.bin"
if (File(ggmlPath).exists()) {
whisperCtx = WhisperJni.initContext(ggmlPath)
nlog("whisper.cpp fallback: OK")
}
ortEnv = OrtEnvironment.getEnvironment()
// Try Qualcomm HfWhisper KV-cache models first (full NPU pipeline)
val hfEncPath = "$path/HfWhisperEncoder.onnx"
val hfDecPath = "$path/HfWhisperDecoder.onnx"
if (File(hfEncPath).exists() && File(hfDecPath).exists()) {
try {
val htpPath = "$nativeLibDir/libQnnHtp.so"
nlog("Loading HfWhisper encoder (QNN NPU)...")
val t0 = System.currentTimeMillis()
val encOpts = OrtSession.SessionOptions()
encOpts.addQnn(mapOf("backend_path" to htpPath))
hfEncoderSession = ortEnv!!.createSession(hfEncPath, encOpts)
val encMs = System.currentTimeMillis() - t0
nlog("HfEncoder NPU loaded: ${encMs}ms")
nlog("Loading HfWhisper decoder (QNN NPU)...")
val t1 = System.currentTimeMillis()
val decOpts = OrtSession.SessionOptions()
decOpts.addQnn(mapOf("backend_path" to htpPath))
hfDecoderSession = ortEnv!!.createSession(hfDecPath, decOpts)
val decMs = System.currentTimeMillis() - t1
nlog("HfDecoder NPU loaded: ${decMs}ms")
// Auto-detect model dimensions from encoder output names
detectModelDimensions()
useHfModels = true
nlog("HfWhisper ready: ${numDecoderLayers} layers, ${numDecoderHeads} heads (full NPU)")
} catch (e: Exception) {
nlog("HfWhisper NPU failed: ${e.message}")
hfEncoderSession?.close(); hfEncoderSession = null
hfDecoderSession?.close(); hfDecoderSession = null
useHfModels = false
}
}
// Fallback: standard ONNX encoder/decoder
if (!useHfModels) {
val encNpuPath = "$path/encoder_npu/model.onnx"
if (File(encNpuPath).exists()) {
try {
val encOpts = OrtSession.SessionOptions()
encOpts.addQnn(mapOf("backend_path" to "$nativeLibDir/libQnnHtp.so"))
nlog("Loading encoder (QNN NPU)...")
val t0 = System.currentTimeMillis()
encoderSession = ortEnv!!.createSession(encNpuPath, encOpts)
nlog("Encoder NPU: ${System.currentTimeMillis() - t0}ms")
} catch (e: Exception) {
nlog("Encoder NPU failed: ${e.message}, trying CPU")
val encOpts = OrtSession.SessionOptions().apply { setIntraOpNumThreads(4) }
encoderSession = ortEnv!!.createSession("$path/encoder_model.onnx", encOpts)
}
} else {
val encOpts = OrtSession.SessionOptions().apply { setIntraOpNumThreads(4) }
nlog("Loading encoder (CPU)...")
encoderSession = ortEnv!!.createSession("$path/encoder_model.onnx", encOpts)
}
val decOpts = OrtSession.SessionOptions().apply { setIntraOpNumThreads(4) }
nlog("Loading decoder (CPU)...")
decoderSession = ortEnv!!.createSession("$path/decoder_model.onnx", decOpts)
}
// Vocab
val vocabFile = File(path, "vocab.json")
if (vocabFile.exists()) {
val json = JSONObject(vocabFile.readText())
val map = mutableMapOf<Int, String>()
json.keys().forEach { k -> map[json.getInt(k)] = k }
vocab = map
nlog("Vocab: ${vocab.size} tokens")
}
loaded = (useHfModels || (encoderSession != null && decoderSession != null))
nlog("Ready: loaded=$loaded, hfModels=$useHfModels")
} catch (e: Exception) {
nlog("ERROR: Load failed: ${e.message}")
e.printStackTrace()
}
}
}
/** Detect number of decoder layers and heads from encoder output metadata */
private fun detectModelDimensions() {
val enc = hfEncoderSession ?: return
// Count outputs: each layer has k_cache_cross_N and v_cache_cross_N
val outputNames = enc.outputNames
numDecoderLayers = outputNames.count { it.startsWith("k_cache_cross_") }
// Get num heads from first k_cache output shape: [num_heads, 1, head_dim, 1500]
val firstKInfo = enc.outputInfo["k_cache_cross_0"]
if (firstKInfo != null) {
val shape = (firstKInfo.info as? ai.onnxruntime.TensorInfo)?.shape
if (shape != null && shape.isNotEmpty()) {
numDecoderHeads = shape[0].toInt()
}
}
// Fallback defaults
if (numDecoderLayers == 0) numDecoderLayers = 6
if (numDecoderHeads == 0) numDecoderHeads = 8
}
override fun isLoaded(): Boolean = loaded
override suspend fun transcribe(
audioData: ShortArray, language: String
): TranscriptionResult = withContext(Dispatchers.IO) {
if (!loaded) return@withContext TranscriptionResult("", 0f, language, 0)
val t0 = System.currentTimeMillis()
try {
// 1. Mel via native C++ (HuggingFace-compatible)
val tMel = System.currentTimeMillis()
val mel = MelExtractor.computeMel(audioData)
?: return@withContext TranscriptionResult("", 0f, language, 0)
val melMs = System.currentTimeMillis() - tMel
nlog("Mel: ${melMs}ms, range [${mel.min()}, ${mel.max()}], mean=${"%.3f".format(mel.average())}")
if (useHfModels) {
val result = transcribeHfNpu(mel)
val totalMs = System.currentTimeMillis() - t0
nlog("\"${result.first}\" mel=${melMs}ms enc=${result.second}ms dec=${result.third}ms total=${totalMs}ms [NPU]")
TranscriptionResult(
result.first.trim(),
0.95f, language, totalMs
)
} else {
val result = transcribeStandard(mel)
val totalMs = System.currentTimeMillis() - t0
nlog("\"${result.first}\" mel=${melMs}ms enc=${result.second}ms dec=${result.third}ms total=${totalMs}ms [ONNX]")
TranscriptionResult(
result.first.trim(),
0.95f, language, totalMs
)
}
} catch (e: Exception) {
nlog("ERROR: ${e.message}")
e.printStackTrace()
if (whisperCtx != 0L) {
val t = System.currentTimeMillis()
val text = WhisperJni.transcribe(whisperCtx, audioData, language)
val cpuMs = System.currentTimeMillis() - t
nlog("[CPU fallback] '$text' ${cpuMs}ms")
TranscriptionResult(text.trim(), 0.9f, language, System.currentTimeMillis() - t0)
} else {
TranscriptionResult("ERROR: ${e.message}", 0f, language, 0)
}
}
}
// ==================== Qualcomm HfWhisper KV-cache NPU path ====================
private fun transcribeHfNpu(mel: FloatArray): Triple<String, Long, Long> {
// --- Encoder ---
val tEnc = System.currentTimeMillis()
val melTensor = createFp16Tensor(mel, longArrayOf(1, 80, 3000))
val encResult = hfEncoderSession!!.run(mapOf("input_features" to melTensor))
melTensor.close()
// Extract cross KV caches (numDecoderLayers pairs)
val crossKvCaches = mutableListOf<Pair<OnnxTensor, OnnxTensor>>()
for (i in 0 until numDecoderLayers) {
val kTensor = encResult["k_cache_cross_$i"].get() as OnnxTensor
val vTensor = encResult["v_cache_cross_$i"].get() as OnnxTensor
crossKvCaches.add(cloneFp16Tensor(kTensor) to cloneFp16Tensor(vTensor))
}
encResult.close()
val encMs = System.currentTimeMillis() - tEnc
nlog("HfEncoder: ${encMs}ms, ${crossKvCaches.size} cross KV pairs")
// --- Decoder with KV caches ---
val tDec = System.currentTimeMillis()
val tokens = decodeHfKvCache(crossKvCaches)
val decMs = System.currentTimeMillis() - tDec
crossKvCaches.forEach { (k, v) -> k.close(); v.close() }
nlog("HfDecoder: ${decMs}ms, ${tokens.size} tokens")
val text = decodeTokens(tokens)
return Triple(text, encMs, decMs)
}
/**
* KV-cache autoregressive decoder following Qualcomm reference exactly.
* Dimensions are auto-detected: numDecoderLayers and numDecoderHeads.
*/
private fun decodeHfKvCache(crossKvCaches: List<Pair<OnnxTensor, OnnxTensor>>): List<Int> {
val tokens = mutableListOf<Int>()
val kvSlots = MEAN_DECODE_LEN - 1 // 199
// Initialize self KV caches to zeros
val selfKvSize_k = numDecoderHeads * 1 * HEAD_DIM * kvSlots
val selfKvSize_v = numDecoderHeads * 1 * kvSlots * HEAD_DIM
val selfKvShape_k = longArrayOf(numDecoderHeads.toLong(), 1, HEAD_DIM.toLong(), kvSlots.toLong())
val selfKvShape_v = longArrayOf(numDecoderHeads.toLong(), 1, kvSlots.toLong(), HEAD_DIM.toLong())
var selfKvCaches = (0 until numDecoderLayers).map {
createZeroFp16Tensor(selfKvSize_k, selfKvShape_k) to
createZeroFp16Tensor(selfKvSize_v, selfKvShape_v)
}.toMutableList()
// Initialize attention mask to MASK_NEG (-100)
val maskData = FloatArray(MEAN_DECODE_LEN) { MASK_NEG }
var positionId = 0
var currentToken = SOT
for (step in 0 until MEAN_DECODE_LEN - 1) {
// Unmask position right-to-left
maskData[MEAN_DECODE_LEN - step - 1] = 0.0f
val inputIdsTensor = OnnxTensor.createTensor(
ortEnv, IntBuffer.wrap(intArrayOf(currentToken)), longArrayOf(1, 1)
)
val maskTensor = createFp16Tensor(maskData, longArrayOf(1, 1, 1, MEAN_DECODE_LEN.toLong()))
val posIdsTensor = OnnxTensor.createTensor(
ortEnv, IntBuffer.wrap(intArrayOf(positionId)), longArrayOf(1)
)
val inputs = LinkedHashMap<String, OnnxTensor>()
inputs["input_ids"] = inputIdsTensor
inputs["attention_mask"] = maskTensor
for (i in 0 until numDecoderLayers) {
inputs["k_cache_self_${i}_in"] = selfKvCaches[i].first
inputs["v_cache_self_${i}_in"] = selfKvCaches[i].second
}
for (i in 0 until numDecoderLayers) {
inputs["k_cache_cross_$i"] = crossKvCaches[i].first
inputs["v_cache_cross_$i"] = crossKvCaches[i].second
}
inputs["position_ids"] = posIdsTensor
val decResult = hfDecoderSession!!.run(inputs)
val logitsTensor = decResult["logits"].get() as OnnxTensor
var tokenId = argmaxFp16Logits(logitsTensor)
// Force transcribe mode: if model chooses <|translate|>, override to <|transcribe|>
// This preserves auto language detection but prevents translation to English
if (tokenId == TRANSLATE) {
nlog("Override: <|translate|> → <|transcribe|> at step $step")
tokenId = TRANSCRIBE
}
val newSelfKv = (0 until numDecoderLayers).map { i ->
val kOut = decResult["k_cache_self_${i}_out"].get() as OnnxTensor
val vOut = decResult["v_cache_self_${i}_out"].get() as OnnxTensor
cloneFp16Tensor(kOut) to cloneFp16Tensor(vOut)
}.toMutableList()
inputIdsTensor.close()
maskTensor.close()
posIdsTensor.close()
selfKvCaches.forEach { (k, v) -> k.close(); v.close() }
decResult.close()
selfKvCaches = newSelfKv
if (step < 10 || step % 20 == 0) {
nlog("Step $step: token=$tokenId '${vocab[tokenId] ?: "?"}' pos=$positionId")
}
if (tokenId == EOT) {
nlog("EOT at step $step")
break
}
if (tokenId < 50257) {
tokens.add(tokenId)
}
currentToken = tokenId
positionId++
}
selfKvCaches.forEach { (k, v) -> k.close(); v.close() }
return tokens
}
// ==================== FP16 helper functions ====================
private fun createFp16Tensor(data: FloatArray, shape: LongArray): OnnxTensor {
val buf = ByteBuffer.allocateDirect(data.size * 2).order(ByteOrder.nativeOrder())
for (f in data) buf.putShort(java.lang.Float.floatToFloat16(f))
buf.rewind()
return OnnxTensor.createTensor(ortEnv, buf, shape, OnnxJavaType.FLOAT16)
}
private fun createZeroFp16Tensor(numElements: Int, shape: LongArray): OnnxTensor {
val zeroFp16 = java.lang.Float.floatToFloat16(0.0f)
val buf = ByteBuffer.allocateDirect(numElements * 2).order(ByteOrder.nativeOrder())
for (i in 0 until numElements) buf.putShort(zeroFp16)
buf.rewind()
return OnnxTensor.createTensor(ortEnv, buf, shape, OnnxJavaType.FLOAT16)
}
private fun cloneFp16Tensor(tensor: OnnxTensor): OnnxTensor {
val shape = tensor.info.shape
val numElements = shape.fold(1L) { acc, v -> acc * v }.toInt()
val srcBuf = tensor.byteBuffer
srcBuf.rewind()
val dstBuf = ByteBuffer.allocateDirect(numElements * 2).order(ByteOrder.nativeOrder())
dstBuf.put(srcBuf)
dstBuf.rewind()
return OnnxTensor.createTensor(ortEnv, dstBuf, shape, OnnxJavaType.FLOAT16)
}
private fun argmaxFp16Logits(tensor: OnnxTensor): Int {
val buf = tensor.byteBuffer
buf.rewind()
var maxIdx = 0
var maxVal = Float.NEGATIVE_INFINITY
for (i in 0 until VOCAB_SIZE) {
val fp16 = buf.getShort()
val fp32 = java.lang.Float.float16ToFloat(fp16)
if (fp32 > maxVal) {
maxVal = fp32
maxIdx = i
}
}
return maxIdx
}
// ==================== Standard ONNX fallback path ====================
private fun transcribeStandard(mel: FloatArray): Triple<String, Long, Long> {
val tEnc = System.currentTimeMillis()
val melTensor = try {
createFp16Tensor(mel, longArrayOf(1, 80, 3000))
} catch (_: Exception) {
OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(mel), longArrayOf(1, 80, 3000))
}
val encResultRaw = encoderSession!!.run(mapOf("input_features" to melTensor))
melTensor.close()
val encRaw = encResultRaw[0] as OnnxTensor
val encShape = encRaw.info.shape
@Suppress("UNCHECKED_CAST")
val encFloats = when (val v = encRaw.value) {
is Array<*> -> {
val arr = v as Array<Array<FloatArray>>
val flat = FloatArray((encShape[0] * encShape[1] * encShape[2]).toInt())
var i = 0; for (b in arr) for (s in b) for (f in s) flat[i++] = f; flat
}
else -> FloatArray(0)
}
val encoderHidden = OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(encFloats), encShape)
encResultRaw.close()
val encMs = System.currentTimeMillis() - tEnc
val tDec = System.currentTimeMillis()
val tokens = decodeStandard(encoderHidden)
encoderHidden.close()
val decMs = System.currentTimeMillis() - tDec
val text = decodeTokens(tokens)
return Triple(text, encMs, decMs)
}
private fun decodeStandard(encoderHidden: OnnxTensor): List<Int> {
val tokens = mutableListOf<Int>()
val allTokens = mutableListOf(SOT.toLong())
for (step in 0 until 200) {
val allIds = allTokens.toLongArray()
val idTensor = OnnxTensor.createTensor(
ortEnv, LongBuffer.wrap(allIds), longArrayOf(1, allIds.size.toLong())
)
val results = decoderSession!!.run(mapOf(
"input_ids" to idTensor,
"encoder_hidden_states" to encoderHidden
))
@Suppress("UNCHECKED_CAST")
val logits = (results[0].value as Array<Array<FloatArray>>)[0]
val lastLogits = logits[logits.size - 1]
var maxIdx = 0
var maxVal = Float.NEGATIVE_INFINITY
for (i in lastLogits.indices) {
if (lastLogits[i] > maxVal) { maxVal = lastLogits[i]; maxIdx = i }
}
idTensor.close()
results.close()
if (maxIdx == EOT) {
nlog("EOT at step $step")
break
}
allTokens.add(maxIdx.toLong())
if (maxIdx < 50257) tokens.add(maxIdx)
if (tokens.size <= 10) {
nlog("Step $step: token=$maxIdx '${vocab[maxIdx] ?: "?"}'")
}
}
return tokens
}
// ==================== Token decoding ====================
private val bpeUnicodeToByte: Map<Int, Int> by lazy {
val map = mutableMapOf<Int, Int>()
for (b in 33..126) map[b] = b
for (b in 161..172) map[b] = b
for (b in 174..255) map[b] = b
var n = 0
for (b in 0..255) {
if (b !in map.values) {
map[256 + n] = b
n++
}
}
map
}
private fun decodeTokens(tokens: List<Int>): String {
if (vocab.isEmpty()) return tokens.joinToString(" ") { "#$it" }
val bytes = mutableListOf<Byte>()
for (t in tokens) {
val w = vocab[t] ?: continue
if (w.startsWith("<|") && w.endsWith("|>")) continue
for (ch in w) {
val b = bpeUnicodeToByte[ch.code]
if (b != null) bytes.add(b.toByte())
}
}
return String(bytes.toByteArray(), Charsets.UTF_8)
}
override fun release() {
if (whisperCtx != 0L) { WhisperJni.freeContext(whisperCtx); whisperCtx = 0 }
hfEncoderSession?.close(); hfDecoderSession?.close()
encoderSession?.close(); decoderSession?.close()
ortEnv?.close()
loaded = false
}
}

View File

@ -0,0 +1,16 @@
package com.kazeia.stt
object WhisperJni {
init {
System.loadLibrary("ggml-base")
System.loadLibrary("ggml-cpu")
System.loadLibrary("ggml")
System.loadLibrary("whisper")
System.loadLibrary("whisper_jni")
}
external fun initContext(modelPath: String): Long
external fun transcribe(contextPtr: Long, audioData: ShortArray, language: String): String
external fun computeMel(contextPtr: Long, audioData: ShortArray): FloatArray?
external fun freeContext(contextPtr: Long)
}

View File

@ -0,0 +1,305 @@
package com.kazeia.stt
import android.util.Log
import com.kazeia.core.SttEngine
import com.kazeia.core.TranscriptionResult
import com.qualcomm.qti.QnnDelegate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tensorflow.lite.Interpreter
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.MappedByteBuffer
import java.nio.channels.FileChannel
class WhisperLiteRtEngine(private val nativeLibDir: String) : SttEngine {
companion object {
private const val TAG = "WhisperLiteRT"
private const val SAMPLE_RATE = 16000
private const val N_MELS = 80
private const val N_FFT = 400
private const val HOP_LENGTH = 160
private const val CHUNK_LENGTH = 30
private const val N_FRAMES = 3000
}
private var interpreter: Interpreter? = null
private var qnnDelegate: QnnDelegate? = null
private var vocab: List<String> = emptyList()
private var melFilters: FloatArray? = null
private var loaded = false
private var useNpu = false
override suspend fun load(modelPath: String?) {
withContext(Dispatchers.IO) {
val path = modelPath ?: return@withContext
try {
val modelFile = File(path, "whisper-base.fr.tflite")
val vocabFile = File(path, "filters_vocab_multilingual.bin")
if (!modelFile.exists()) {
Log.e(TAG, "Model not found: ${modelFile.absolutePath}")
return@withContext
}
// Load mel filters and vocab from binary file
if (vocabFile.exists()) {
loadFiltersAndVocab(vocabFile)
}
// Try QNN delegate for NPU
try {
val options = QnnDelegate.Options().apply {
setBackendType(QnnDelegate.Options.BackendType.HTP_BACKEND)
setSkelLibraryDir(nativeLibDir)
}
qnnDelegate = QnnDelegate(options)
useNpu = true
Log.i(TAG, "QNN HTP delegate created (NPU)")
} catch (e: Exception) {
Log.w(TAG, "QNN delegate not available: ${e.message}")
useNpu = false
}
// Create interpreter
val interpreterOptions = Interpreter.Options().apply {
setNumThreads(4)
qnnDelegate?.let { addDelegate(it) }
}
Log.i(TAG, "Loading model: ${modelFile.length() / 1024 / 1024} MB")
val mappedModel = loadMappedFile(modelFile)
interpreter = Interpreter(mappedModel, interpreterOptions)
loaded = true
Log.i(TAG, "Whisper-Base FR loaded (NPU=$useNpu)")
// Log model info
val inputCount = interpreter!!.inputTensorCount
val outputCount = interpreter!!.outputTensorCount
Log.i(TAG, "Model: $inputCount inputs, $outputCount outputs")
for (i in 0 until inputCount) {
val t = interpreter!!.getInputTensor(i)
Log.i(TAG, " Input $i: ${t.name()} shape=${t.shape().contentToString()} dtype=${t.dataType()}")
}
for (i in 0 until outputCount) {
val t = interpreter!!.getOutputTensor(i)
Log.i(TAG, " Output $i: ${t.name()} shape=${t.shape().contentToString()} dtype=${t.dataType()}")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load Whisper LiteRT", e)
}
}
}
override fun isLoaded(): Boolean = loaded
override suspend fun transcribe(
audioData: ShortArray,
language: String
): TranscriptionResult = withContext(Dispatchers.IO) {
if (!loaded || interpreter == null) {
return@withContext TranscriptionResult("", 0f, language, 0)
}
val startTime = System.currentTimeMillis()
try {
// Auto-gain normalize
var maxAbs = 0f
for (s in audioData) {
val abs = if (s < 0) -s.toFloat() else s.toFloat()
if (abs > maxAbs) maxAbs = abs
}
val gain = if (maxAbs > 10f) 32768f * 0.9f / maxAbs else 1f
val floatAudio = FloatArray(audioData.size) { audioData[it] * gain / 32768f }
Log.i(TAG, "Transcribing ${audioData.size} samples, gain=${"%.1f".format(gain)}")
// Compute mel spectrogram using whisper.cpp JNI (correct implementation)
// Actually, use our own mel with the correct filters from the vocab file
val mel = computeMel(floatAudio)
// Prepare input buffer
val inputBuffer = ByteBuffer.allocateDirect(1 * N_MELS * N_FRAMES * 4)
.order(ByteOrder.nativeOrder())
for (v in mel) inputBuffer.putFloat(v)
inputBuffer.rewind()
// Run inference
Log.i(TAG, "Running inference...")
val infTime = System.currentTimeMillis()
// The output is token IDs
val outputBuffer = ByteBuffer.allocateDirect(1 * 200 * 4)
.order(ByteOrder.nativeOrder())
interpreter!!.run(inputBuffer, outputBuffer)
Log.i(TAG, "Inference done in ${System.currentTimeMillis() - infTime}ms")
// Decode output tokens
outputBuffer.rewind()
val tokens = mutableListOf<Int>()
for (i in 0 until 200) {
val token = outputBuffer.getInt()
if (token == 50257) break // EOT
if (token > 0) tokens.add(token)
}
val text = decodeTokens(tokens)
val elapsed = System.currentTimeMillis() - startTime
Log.i(TAG, "Result: \"$text\" (${elapsed}ms, NPU=$useNpu)")
TranscriptionResult(text.trim(), 0.9f, language, elapsed)
} catch (e: Exception) {
Log.e(TAG, "Transcription error", e)
TranscriptionResult("", 0f, language, 0)
}
}
private fun loadFiltersAndVocab(file: File) {
try {
val raf = RandomAccessFile(file, "r")
val buf = ByteBuffer.allocate(file.length().toInt()).order(ByteOrder.LITTLE_ENDIAN)
raf.channel.read(buf)
buf.rewind()
// Format: n_mels(4) n_fft(4) filters(n_mels*n_fft*4) n_vocab(4) vocab_entries...
val nMels = buf.int
val nFft = buf.int
if (nMels == 80 && nFft == 201) {
melFilters = FloatArray(nMels * nFft)
for (i in 0 until nMels * nFft) {
melFilters!![i] = buf.float
}
Log.i(TAG, "Loaded mel filters: ${nMels}x${nFft}")
} else {
Log.w(TAG, "Unexpected filter dims: ${nMels}x${nFft}, trying alternate format")
buf.rewind()
// The DocWolle format might be different
// Try reading as: n_vocab(4) then vocab, then filters
}
// Read vocab
if (buf.remaining() > 4) {
val nVocab = buf.int
val vocabList = mutableListOf<String>()
for (i in 0 until nVocab) {
if (buf.remaining() < 4) break
val len = buf.int
if (len <= 0 || len > 1000 || buf.remaining() < len) break
val bytes = ByteArray(len)
buf.get(bytes)
vocabList.add(String(bytes, Charsets.UTF_8))
}
vocab = vocabList
Log.i(TAG, "Loaded vocab: ${vocab.size} tokens")
}
raf.close()
} catch (e: Exception) {
Log.e(TAG, "Failed to load filters/vocab", e)
}
}
private fun computeMel(audio: FloatArray): FloatArray {
// Use whisper.cpp for mel computation (via JNI) if available
// Otherwise fall back to simple computation
// For now, delegate to WhisperJni which already handles mel correctly
val mel = FloatArray(N_MELS * N_FRAMES)
// Pad audio to 30s
val targetLen = CHUNK_LENGTH * SAMPLE_RATE
val padded = FloatArray(targetLen)
System.arraycopy(audio, 0, padded, 0, minOf(audio.size, targetLen))
// Use whisper.cpp mel computation via the existing context
// This is a workaround - we compute mel features using whisper.cpp's internal
// For the TFLite model, we need the mel as input
val window = FloatArray(N_FFT) { i ->
(0.5 * (1.0 - Math.cos(2.0 * Math.PI * i / N_FFT))).toFloat()
}
val filters = melFilters
if (filters == null || filters.size != N_MELS * (N_FFT / 2 + 1)) {
Log.w(TAG, "Mel filters not loaded, returning zeros")
return mel
}
val fftSize = N_FFT / 2 + 1
// Reflect pad
val padAmount = N_FFT / 2
val reflected = FloatArray(padded.size + 2 * padAmount)
for (i in 0 until padAmount) reflected[padAmount - 1 - i] = padded[minOf(i + 1, padded.size - 1)]
System.arraycopy(padded, 0, reflected, padAmount, padded.size)
for (i in 0 until padAmount) reflected[padAmount + padded.size + i] = padded[maxOf(padded.size - 2 - i, 0)]
// STFT + mel (using pre-computed twiddle factors)
val cosTable = Array(fftSize) { k -> FloatArray(N_FFT) { n -> Math.cos(-2.0 * Math.PI * k * n / N_FFT).toFloat() } }
val sinTable = Array(fftSize) { k -> FloatArray(N_FFT) { n -> Math.sin(-2.0 * Math.PI * k * n / N_FFT).toFloat() } }
for (frame in 0 until N_FRAMES) {
val offset = frame * HOP_LENGTH
val windowed = FloatArray(N_FFT) { i ->
val idx = offset + i
if (idx < reflected.size) reflected[idx] * window[i] else 0f
}
val power = FloatArray(fftSize)
for (k in 0 until fftSize) {
var re = 0f; var im = 0f
for (n in 0 until N_FFT) { re += windowed[n] * cosTable[k][n]; im += windowed[n] * sinTable[k][n] }
power[k] = re * re + im * im
}
for (m in 0 until N_MELS) {
var sum = 0f
for (k in 0 until fftSize) sum += filters[m * fftSize + k] * power[k]
mel[m * N_FRAMES + frame] = Math.log10(maxOf(sum.toDouble(), 1e-10)).toFloat()
}
}
val maxVal = mel.max()
for (i in mel.indices) {
mel[i] = maxOf(mel[i], maxVal - 8f)
mel[i] = (mel[i] + 4f) / 4f
}
return mel
}
private fun decodeTokens(tokens: List<Int>): String {
if (vocab.isEmpty()) return tokens.joinToString(" ") { "#$it" }
val sb = StringBuilder()
for (t in tokens) {
if (t < 0 || t >= vocab.size) continue
val word = vocab[t]
if (word.startsWith("<|") && word.endsWith("|>")) continue
sb.append(word.replace("Ġ", " "))
}
return sb.toString()
}
private fun loadMappedFile(file: File): MappedByteBuffer {
val raf = RandomAccessFile(file, "r")
val channel = raf.channel
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size())
raf.close()
return mapped
}
override fun release() {
interpreter?.close()
qnnDelegate?.close()
interpreter = null
qnnDelegate = null
loaded = false
}
}

View File

@ -0,0 +1,165 @@
package com.kazeia.stt
import android.util.Log
import com.kazeia.core.SttEngine
import com.kazeia.core.TranscriptionResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Whisper STT on NPU via ExecuTorch + QNN.
* Pipeline: whisper.cpp (mel on CPU) ExecuTorch (encoder+decoder on NPU)
* Falls back to whisper.cpp CPU if NPU fails.
*/
class WhisperNpuSttEngine : SttEngine {
companion object {
private const val TAG = "WhisperNPU"
private const val ET_DIR = "/data/local/tmp/kazeia-et"
}
private var whisperCpuCtx: Long = 0 // whisper.cpp context for mel computation
private var loaded = false
private var npuAvailable = false
override suspend fun load(modelPath: String?) {
withContext(Dispatchers.IO) {
try {
// Load whisper.cpp for mel computation
val ggmlModel = "$modelPath/ggml-base.bin"
if (File(ggmlModel).exists()) {
whisperCpuCtx = WhisperJni.initContext(ggmlModel)
Log.i(TAG, "whisper.cpp loaded for mel/fallback")
}
// Check if NPU runner is available
val check = execRoot("ls $ET_DIR/qnn_whisper_runner $ET_DIR/whisper_qnn_16a8w.pte 2>&1")
npuAvailable = !check.contains("No such file")
Log.i(TAG, "NPU runner available: $npuAvailable")
loaded = whisperCpuCtx != 0L
Log.i(TAG, "WhisperNPU ready (npu=$npuAvailable, cpu_fallback=${whisperCpuCtx != 0L})")
} catch (e: Exception) {
Log.e(TAG, "Failed to load", e)
}
}
}
override fun isLoaded(): Boolean = loaded
override suspend fun transcribe(
audioData: ShortArray,
language: String
): TranscriptionResult = withContext(Dispatchers.IO) {
if (!loaded) return@withContext TranscriptionResult("", 0f, language, 0)
if (npuAvailable) {
try {
return@withContext transcribeNpu(audioData, language)
} catch (e: Exception) {
Log.e(TAG, "NPU transcription failed, fallback to CPU: ${e.message}")
}
}
// Fallback: whisper.cpp CPU
transcribeCpu(audioData, language)
}
private fun transcribeNpu(audioData: ShortArray, language: String): TranscriptionResult {
val t0 = System.currentTimeMillis()
// 1. Compute mel via whisper.cpp (fast, ~15ms)
val melData = WhisperJni.computeMel(whisperCpuCtx, audioData)
?: return TranscriptionResult("", 0f, language, 0)
val melMs = System.currentTimeMillis() - t0
Log.i(TAG, "Mel: ${melMs}ms (${melData.size} values)")
// 2. Save mel as binary file for runner
val melFile = "$ET_DIR/mel_input.raw"
val inputListFile = "$ET_DIR/input_list.txt"
saveMelBinary(melData, melFile)
execRoot("echo '$melFile' > $inputListFile")
// 3. Run ExecuTorch whisper on NPU
val t1 = System.currentTimeMillis()
val output = execRoot(
"cd $ET_DIR && " +
"export LD_LIBRARY_PATH=$ET_DIR && " +
"export ADSP_LIBRARY_PATH=$ET_DIR && " +
"./qnn_whisper_runner " +
"--model_path whisper_qnn_16a8w.pte " +
"--tokenizer_json_path tokenizer.json " +
"--input_list_path input_list.txt " +
"--output_folder_path outputs " +
"--seq_len 128 2>&1"
)
val npuMs = System.currentTimeMillis() - t1
Log.i(TAG, "NPU inference: ${npuMs}ms")
// 4. Read output
val result = execRoot("cat $ET_DIR/outputs/*.txt 2>/dev/null").trim()
val totalMs = System.currentTimeMillis() - t0
Log.i(TAG, "NPU result: '$result' (total=${totalMs}ms, mel=${melMs}ms, npu=${npuMs}ms)")
return TranscriptionResult(
text = result,
confidence = 0.95f,
language = language,
durationMs = totalMs
)
}
private fun transcribeCpu(audioData: ShortArray, language: String): TranscriptionResult {
val t0 = System.currentTimeMillis()
val text = WhisperJni.transcribe(whisperCpuCtx, audioData, language)
val elapsed = System.currentTimeMillis() - t0
Log.i(TAG, "CPU fallback: '$text' (${elapsed}ms)")
return TranscriptionResult(text.trim(), 0.9f, language, elapsed)
}
private fun saveMelBinary(melData: FloatArray, path: String) {
// Write mel as raw float32 binary via root (app can't write to ET_DIR directly)
val tmpFile = "/data/local/tmp/kazeia/mel_tmp.raw"
try {
// Write locally first
val localTmp = File.createTempFile("mel", ".raw")
FileOutputStream(localTmp).use { fos ->
val buf = ByteBuffer.allocate(melData.size * 4).order(ByteOrder.LITTLE_ENDIAN)
for (f in melData) buf.putFloat(f)
fos.write(buf.array())
}
// Copy to ET_DIR via adb/root
val localPath = localTmp.absolutePath
execRoot("cp $localPath $path && rm $localPath")
localTmp.delete()
} catch (e: Exception) {
Log.e(TAG, "Failed to save mel: ${e.message}")
}
}
private fun execRoot(cmd: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
val result = process.inputStream.bufferedReader().readText()
val error = process.errorStream.bufferedReader().readText()
process.waitFor()
result.ifEmpty { error }
} catch (e: Exception) {
Log.e(TAG, "execRoot failed: ${e.message}")
""
}
}
override fun release() {
if (whisperCpuCtx != 0L) {
WhisperJni.freeContext(whisperCpuCtx)
whisperCpuCtx = 0
}
loaded = false
}
}

View File

@ -0,0 +1,72 @@
package com.kazeia.stt
import android.util.Log
import com.kazeia.core.SttEngine
import com.kazeia.core.TranscriptionResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class WhisperSttEngine : SttEngine {
companion object {
private const val TAG = "WhisperSTT"
}
private var contextPtr: Long = 0
private var loaded = false
override suspend fun load(modelPath: String?) {
withContext(Dispatchers.IO) {
val path = modelPath ?: return@withContext
val modelFile = "$path/ggml-base.bin"
Log.i(TAG, "Loading whisper.cpp model: $modelFile")
try {
contextPtr = WhisperJni.initContext(modelFile)
if (contextPtr == 0L) {
Log.e(TAG, "Failed to init whisper context")
return@withContext
}
loaded = true
Log.i(TAG, "whisper.cpp loaded successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to load whisper.cpp", e)
}
}
}
override fun isLoaded(): Boolean = loaded
override suspend fun transcribe(
audioData: ShortArray,
language: String
): TranscriptionResult = withContext(Dispatchers.IO) {
if (!loaded) return@withContext TranscriptionResult("", 0f, language, 0)
val startTime = System.currentTimeMillis()
try {
Log.i(TAG, "Transcribing ${audioData.size} samples (${audioData.size / 16000f}s)...")
val text = WhisperJni.transcribe(contextPtr, audioData, language)
val elapsed = System.currentTimeMillis() - startTime
Log.i(TAG, "Result: \"$text\" (${elapsed}ms)")
TranscriptionResult(
text = text.trim(),
confidence = 0.9f,
language = language,
durationMs = elapsed
)
} catch (e: Exception) {
Log.e(TAG, "Transcription error", e)
TranscriptionResult("", 0f, language, 0)
}
}
override fun release() {
if (contextPtr != 0L) {
WhisperJni.freeContext(contextPtr)
contextPtr = 0
loaded = false
}
}
}

View File

@ -0,0 +1,99 @@
package com.kazeia.tts
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import com.kazeia.core.TtsEngine
import com.kazeia.core.TtsResult
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.Locale
import kotlin.coroutines.resume
class AndroidTtsEngine(private val context: Context) : TtsEngine {
companion object {
private const val TAG = "AndroidTtsEngine"
}
private var tts: TextToSpeech? = null
private var ready = false
override suspend fun load(modelPath: String?, voiceId: String?) {
suspendCancellableCoroutine { continuation ->
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.FRANCE
tts?.setSpeechRate(0.95f)
tts?.setPitch(1.0f)
ready = true
Log.i(TAG, "Android TTS initialized")
} else {
Log.e(TAG, "TTS init failed: $status")
}
continuation.resume(Unit)
}
}
}
override fun isLoaded(): Boolean = ready
override suspend fun synthesize(text: String, language: String): TtsResult {
return TtsResult(
audioData = ShortArray(0),
sampleRate = 24000,
durationMs = 0
)
}
override suspend fun synthesizeAndPlay(
text: String,
language: String,
onStart: (() -> Unit)?,
onComplete: (() -> Unit)?
) {
if (!ready) {
onComplete?.invoke()
return
}
suspendCancellableCoroutine { continuation ->
val utteranceId = "kazeia_${System.currentTimeMillis()}"
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(id: String?) {
onStart?.invoke()
}
override fun onDone(id: String?) {
onComplete?.invoke()
continuation.resume(Unit)
}
@Deprecated("Deprecated in Java")
override fun onError(id: String?) {
Log.e(TAG, "TTS error for utterance: $id")
onComplete?.invoke()
continuation.resume(Unit)
}
})
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
continuation.invokeOnCancellation {
tts?.stop()
}
}
}
override fun stop() {
tts?.stop()
}
override fun release() {
tts?.stop()
tts?.shutdown()
tts = null
ready = false
}
}

View File

@ -0,0 +1,499 @@
package com.kazeia.tts
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.util.Log
import com.kazeia.core.TtsEngine
import com.kazeia.core.TtsResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.FloatBuffer
import java.nio.LongBuffer
import java.nio.ShortBuffer
import kotlin.coroutines.resume
import kotlin.math.min
class ChatterboxTtsEngine(
private val onLog: ((String) -> Unit)? = null
) : TtsEngine {
companion object {
private const val TAG = "ChatterboxTTS"
private const val SR = 24000
private const val START_SPEECH = 6561L
private const val STOP_SPEECH = 6562L
private const val NUM_LAYERS = 30
private const val NUM_KV_HEADS = 16
private const val HEAD_DIM = 64
private const val MAX_NEW_TOKENS = 512
private const val REP_PENALTY = 1.2f
}
private fun nlog(msg: String) {
Log.i(TAG, msg)
onLog?.invoke("[TTS] $msg")
}
private var ortEnv: OrtEnvironment? = null
private var speechEncoder: OrtSession? = null
private var embedTokens: OrtSession? = null
private var languageModel: OrtSession? = null
private var decoder: OrtSession? = null
private var tokenizer: SimpleTokenizer? = null
private var loaded = false
// Cached voice embeddings per voice
private var cachedVoiceId: String? = null
private var cachedCondEmb: FloatArray? = null
private var cachedPromptToken: LongArray? = null
private var cachedRefXvec: FloatArray? = null
private var cachedPromptFeat: FloatArray? = null
private var cachedCondEmbShape: LongArray? = null
private var cachedPromptTokenShape: LongArray? = null
private var cachedRefXvecShape: LongArray? = null
private var cachedPromptFeatShape: LongArray? = null
private var currentVoicePath: String? = null
private var audioTrack: AudioTrack? = null
override suspend fun load(modelPath: String?, voiceId: String?) {
withContext(Dispatchers.IO) {
val path = modelPath ?: return@withContext
try {
val t0 = System.currentTimeMillis()
ortEnv = OrtEnvironment.getEnvironment()
val onnxDir = "$path/onnx"
val opts = OrtSession.SessionOptions().apply { setIntraOpNumThreads(4) }
var t = System.currentTimeMillis()
nlog("Loading embed_tokens...")
embedTokens = ortEnv!!.createSession("$onnxDir/embed_tokens.onnx", opts)
nlog("embed_tokens: ${System.currentTimeMillis() - t}ms")
t = System.currentTimeMillis()
nlog("Loading speech_encoder...")
speechEncoder = ortEnv!!.createSession("$onnxDir/speech_encoder.onnx", opts)
nlog("speech_encoder: ${System.currentTimeMillis() - t}ms")
t = System.currentTimeMillis()
nlog("Loading language_model (q4f16)...")
languageModel = ortEnv!!.createSession("$onnxDir/language_model_q4f16.onnx", opts)
nlog("language_model: ${System.currentTimeMillis() - t}ms")
t = System.currentTimeMillis()
nlog("Loading conditional_decoder...")
decoder = ortEnv!!.createSession("$onnxDir/conditional_decoder.onnx", opts)
nlog("conditional_decoder: ${System.currentTimeMillis() - t}ms")
val tokFile = File(path, "tokenizer.json")
if (tokFile.exists()) {
tokenizer = SimpleTokenizer(tokFile)
}
loaded = true
nlog("Chatterbox loaded in ${System.currentTimeMillis() - t0}ms")
} catch (e: Exception) {
Log.e(TAG, "Failed to load Chatterbox", e)
}
}
}
override fun isLoaded(): Boolean = loaded
fun setVoice(voicePath: String) {
currentVoicePath = voicePath
// Invalidate cache if voice changed
if (cachedVoiceId != voicePath) {
cachedVoiceId = null
}
}
override suspend fun synthesize(text: String, language: String): TtsResult {
return withContext(Dispatchers.IO) {
val audioData = generateSpeech(text, language)
TtsResult(audioData = audioData, sampleRate = SR, durationMs = audioData.size * 1000L / SR)
}
}
override suspend fun synthesizeAndPlay(
text: String,
language: String,
onStart: (() -> Unit)?,
onComplete: (() -> Unit)?
) {
val result = synthesize(text, language)
if (result.audioData.isEmpty()) {
onComplete?.invoke()
return
}
withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
onStart?.invoke()
playAudio(result.audioData, SR) {
onComplete?.invoke()
if (cont.isActive) cont.resume(Unit)
}
cont.invokeOnCancellation { stop() }
}
}
}
override fun stop() {
audioTrack?.apply {
try { stop() } catch (_: Exception) {}
release()
}
audioTrack = null
}
private suspend fun generateSpeech(text: String, language: String): ShortArray {
if (!loaded || tokenizer == null) return ShortArray(0)
val voicePath = currentVoicePath ?: return ShortArray(0)
val startTime = System.currentTimeMillis()
val taggedText = "[${language.lowercase()}]$text"
nlog("Generating: '$taggedText' with voice ${File(voicePath).name}")
try {
// 1. Tokenize
val inputIds = tokenizer!!.encode(taggedText)
val positionIds = LongArray(inputIds.size) { i ->
if (inputIds[i] >= START_SPEECH) 0L else i.toLong() - 1
}
// 2. Encode reference voice (cache if same voice)
if (cachedVoiceId != voicePath) {
encodeVoice(voicePath)
}
if (cachedCondEmb == null) return ShortArray(0)
// 3. Get initial embeddings
val embedResult = embedTokens!!.run(mapOf(
"input_ids" to OnnxTensor.createTensor(ortEnv, LongBuffer.wrap(inputIds), longArrayOf(1, inputIds.size.toLong())),
"position_ids" to OnnxTensor.createTensor(ortEnv, LongBuffer.wrap(positionIds), longArrayOf(1, positionIds.size.toLong())),
"exaggeration" to OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(floatArrayOf(0.5f)), longArrayOf(1))
))
val inputsEmbeds = (embedResult[0].value as Array<Array<FloatArray>>)
embedResult.close()
// 4. Prepend conditioning embedding
val condEmb = cachedCondEmb!!
val condShape = cachedCondEmbShape!!
val condSeqLen = condShape[1].toInt()
val hiddenDim = condShape[2].toInt()
val textSeqLen = inputsEmbeds[0].size
val totalSeqLen = condSeqLen + textSeqLen
val combinedEmbeds = FloatArray(totalSeqLen * hiddenDim)
System.arraycopy(condEmb, 0, combinedEmbeds, 0, condEmb.size)
for (i in 0 until textSeqLen) {
System.arraycopy(inputsEmbeds[0][i], 0, combinedEmbeds, (condSeqLen + i) * hiddenDim, hiddenDim)
}
// 5. Autoregressive generation
val generateTokens = mutableListOf(START_SPEECH)
var currentEmbeds = combinedEmbeds
var currentSeqLen = totalSeqLen
var attnMask = LongArray(totalSeqLen) { 1L }
// Init empty KV cache
val pastKv = mutableMapOf<String, FloatArray>()
for (l in 0 until NUM_LAYERS) {
for (kv in listOf("key", "value")) {
pastKv["past_key_values.$l.$kv"] = FloatArray(0)
}
}
Log.i(TAG, "Generating tokens...")
val genStart = System.currentTimeMillis()
for (i in 0 until MAX_NEW_TOKENS) {
// Build LM inputs
val lmInputs = mutableMapOf<String, OnnxTensor>()
lmInputs["inputs_embeds"] = OnnxTensor.createTensor(
ortEnv, FloatBuffer.wrap(currentEmbeds),
longArrayOf(1, currentSeqLen.toLong(), hiddenDim.toLong())
)
lmInputs["attention_mask"] = OnnxTensor.createTensor(
ortEnv, LongBuffer.wrap(attnMask), longArrayOf(1, attnMask.size.toLong())
)
// Add KV cache (fp16 for q4f16 model)
for (l in 0 until NUM_LAYERS) {
for (kv in listOf("key", "value")) {
val key = "past_key_values.$l.$kv"
val data = pastKv[key]!!
val pastLen = if (data.isEmpty()) 0 else data.size / (NUM_KV_HEADS * HEAD_DIM)
// Convert to fp16
val fp16Buf = java.nio.ByteBuffer.allocateDirect(data.size * 2)
.order(java.nio.ByteOrder.nativeOrder())
for (f in data) fp16Buf.putShort(java.lang.Float.floatToFloat16(f))
fp16Buf.rewind()
lmInputs[key] = OnnxTensor.createTensor(
ortEnv, fp16Buf,
longArrayOf(1, NUM_KV_HEADS.toLong(), pastLen.toLong(), HEAD_DIM.toLong()),
ai.onnxruntime.OnnxJavaType.FLOAT16
)
}
}
val lmResults = languageModel!!.run(lmInputs)
// Get logits (last position)
val logits = (lmResults[0].value as Array<Array<FloatArray>>)[0].last()
// Apply repetition penalty
val tokenArray = generateTokens.toLongArray()
for (tid in tokenArray) {
val idx = tid.toInt()
if (idx < logits.size) {
logits[idx] = if (logits[idx] < 0) logits[idx] * REP_PENALTY
else logits[idx] / REP_PENALTY
}
}
// Argmax
var nextToken = 0L
var maxVal = Float.NEGATIVE_INFINITY
for (j in logits.indices) {
if (logits[j] > maxVal) { maxVal = logits[j]; nextToken = j.toLong() }
}
generateTokens.add(nextToken)
// Update KV cache from outputs (present.0.key, present.0.value, ...)
for (l in 0 until NUM_LAYERS) {
for (kv in listOf("key", "value")) {
val tensor = lmResults["present.$l.$kv"].get() as OnnxTensor
val buf = tensor.floatBuffer
val data = FloatArray(buf.remaining())
buf.get(data)
pastKv["past_key_values.$l.$kv"] = data
}
}
lmResults.close()
for (v in lmInputs.values) v.close()
if (nextToken == STOP_SPEECH) break
// Prepare next step: embed the new token
val nextEmbed = embedTokens!!.run(mapOf(
"input_ids" to OnnxTensor.createTensor(ortEnv, LongBuffer.wrap(longArrayOf(nextToken)), longArrayOf(1, 1)),
"position_ids" to OnnxTensor.createTensor(ortEnv, LongBuffer.wrap(longArrayOf(i.toLong() + 1)), longArrayOf(1, 1)),
"exaggeration" to OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(floatArrayOf(0.5f)), longArrayOf(1))
))
val nextEmbArr = (nextEmbed[0].value as Array<Array<FloatArray>>)[0][0]
nextEmbed.close()
currentEmbeds = nextEmbArr
currentSeqLen = 1
attnMask = LongArray(attnMask.size + 1) { 1L }
if (i % 50 == 0) Log.d(TAG, "Token $i/$MAX_NEW_TOKENS")
}
val numTokens = generateTokens.size - 2
val genTime = System.currentTimeMillis() - genStart
nlog("Generated $numTokens tokens in ${genTime}ms (${numTokens * 1000f / genTime} tok/s)")
// 6. Decode to audio
Log.i(TAG, "Decoding to audio...")
val speechTokens = generateTokens.subList(1, generateTokens.size - 1).toLongArray()
val allTokens = cachedPromptToken!! + speechTokens
val wavResult = decoder!!.run(mapOf(
"speech_tokens" to OnnxTensor.createTensor(ortEnv, LongBuffer.wrap(allTokens), longArrayOf(1, allTokens.size.toLong())),
"speaker_embeddings" to OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(cachedRefXvec!!), cachedRefXvecShape!!),
"speaker_features" to OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(cachedPromptFeat!!), cachedPromptFeatShape!!)
))
val wavFloat = (wavResult[0].value as Array<FloatArray>)[0]
wavResult.close()
// Convert float to short PCM
val wavShort = ShortArray(wavFloat.size) { (wavFloat[it].coerceIn(-1f, 1f) * 32767).toInt().toShort() }
val totalTime = System.currentTimeMillis() - startTime
nlog("TTS done: ${wavShort.size.toFloat() / SR}s audio in ${totalTime}ms")
return wavShort
} catch (e: Exception) {
Log.e(TAG, "TTS generation error", e)
return ShortArray(0)
}
}
private fun encodeVoice(voicePath: String) {
Log.i(TAG, "Encoding voice: $voicePath")
try {
// Load WAV file as float array
val audioFloat = loadWav(voicePath)
if (audioFloat.isEmpty()) {
Log.e(TAG, "Failed to load voice file")
return
}
val result = speechEncoder!!.run(mapOf(
"audio_values" to OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(audioFloat), longArrayOf(1, audioFloat.size.toLong()))
))
// Extract outputs by name
val condEmb = result.get("audio_features").get() as OnnxTensor
cachedCondEmbShape = condEmb.info.shape
val condBuf = condEmb.floatBuffer
cachedCondEmb = FloatArray(condBuf.remaining()).also { condBuf.get(it) }
val promptToken = result.get("audio_tokens").get() as OnnxTensor
cachedPromptTokenShape = promptToken.info.shape
val ptBuf = promptToken.longBuffer
cachedPromptToken = LongArray(ptBuf.remaining()).also { ptBuf.get(it) }
val refXvec = result.get("speaker_embeddings").get() as OnnxTensor
cachedRefXvecShape = refXvec.info.shape
val xvBuf = refXvec.floatBuffer
cachedRefXvec = FloatArray(xvBuf.remaining()).also { xvBuf.get(it) }
val promptFeat = result.get("speaker_features").get() as OnnxTensor
cachedPromptFeatShape = promptFeat.info.shape
val pfBuf = promptFeat.floatBuffer
cachedPromptFeat = FloatArray(pfBuf.remaining()).also { pfBuf.get(it) }
result.close()
cachedVoiceId = voicePath
Log.i(TAG, "Voice encoded: condEmb=${cachedCondEmbShape?.contentToString()}, promptTokens=${cachedPromptToken?.size}")
} catch (e: Exception) {
Log.e(TAG, "Voice encoding error", e)
}
}
private fun loadWav(path: String): FloatArray {
try {
val file = File(path)
val bytes = file.readBytes()
// Simple WAV parser - find data chunk
val dataStr = "data"
var dataOffset = -1
for (i in 0 until bytes.size - 4) {
if (bytes[i] == 'd'.code.toByte() && bytes[i + 1] == 'a'.code.toByte() &&
bytes[i + 2] == 't'.code.toByte() && bytes[i + 3] == 'a'.code.toByte()) {
dataOffset = i + 8 // skip "data" + 4 bytes size
break
}
}
if (dataOffset < 0) return FloatArray(0)
// Read as 16-bit PCM
val numSamples = (bytes.size - dataOffset) / 2
val result = FloatArray(numSamples)
for (i in 0 until numSamples) {
val lo = bytes[dataOffset + i * 2].toInt() and 0xFF
val hi = bytes[dataOffset + i * 2 + 1].toInt()
val sample = (hi shl 8) or lo
result[i] = sample.toShort().toFloat() / 32768f
}
Log.i(TAG, "Loaded WAV: $numSamples samples from $path")
return result
} catch (e: Exception) {
Log.e(TAG, "Failed to load WAV: $path", e)
return FloatArray(0)
}
}
private fun playAudio(audioData: ShortArray, sampleRate: Int, onComplete: () -> Unit) {
stop()
val bufferSize = audioData.size * 2
audioTrack = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build())
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build())
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STATIC)
.build()
audioTrack?.apply {
write(audioData, 0, audioData.size)
setNotificationMarkerPosition(audioData.size)
setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
override fun onMarkerReached(track: AudioTrack?) { onComplete() }
override fun onPeriodicNotification(track: AudioTrack?) {}
})
play()
}
}
override fun release() {
stop()
speechEncoder?.close()
embedTokens?.close()
languageModel?.close()
decoder?.close()
ortEnv?.close()
loaded = false
}
}
/**
* Simple BPE tokenizer using tokenizer.json from HuggingFace
*/
class SimpleTokenizer(file: File) {
private val vocab: Map<String, Long>
init {
val json = JSONObject(file.readText())
val model = json.getJSONObject("model")
val vocabObj = model.getJSONObject("vocab")
val map = mutableMapOf<String, Long>()
vocabObj.keys().forEach { key ->
map[key] = vocabObj.getLong(key)
}
vocab = map
}
fun encode(text: String): LongArray {
// Simplified: character-level encoding with vocab lookup
// For production, implement proper BPE merge
val tokens = mutableListOf<Long>()
var i = 0
while (i < text.length) {
var bestLen = 0
var bestToken = 0L
// Greedy longest match
for (len in min(text.length - i, 20) downTo 1) {
val sub = text.substring(i, i + len)
val key = if (i == 0 || text[i - 1] == ' ') "Ġ$sub" else sub
val token = vocab[key] ?: vocab[sub]
if (token != null) {
bestLen = len
bestToken = token
break
}
}
if (bestLen > 0) {
tokens.add(bestToken)
i += bestLen
} else {
// Unknown char, skip
i++
}
}
return tokens.toLongArray()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
package com.kazeia.ui
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
/**
* Minimal audio recording test no dependencies on KazeiaService or VAD.
* Returns a diagnostic string.
*/
object AudioTest {
@SuppressLint("MissingPermission")
fun testMicrophone(): String {
val sb = StringBuilder()
val sampleRate = 16000
val channelConfig = AudioFormat.CHANNEL_IN_MONO
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
// Step 1: test multiple sample rates
val rates = listOf(16000, 44100, 48000, 8000, 22050)
for (rate in rates) {
val buf = AudioRecord.getMinBufferSize(rate, channelConfig, audioFormat)
sb.appendLine("[AudioTest] rate=${rate}Hz -> minBuf=$buf")
}
// Find first working rate
val workingRate = rates.firstOrNull {
AudioRecord.getMinBufferSize(it, channelConfig, audioFormat) > 0
}
if (workingRate == null) {
sb.appendLine("[AudioTest] ALL SAMPLE RATES FAIL — audio subsystem broken!")
// Try stereo
val stereoBuf = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_STEREO, audioFormat)
sb.appendLine("[AudioTest] 48kHz stereo -> minBuf=$stereoBuf")
return sb.toString()
}
sb.appendLine("[AudioTest] Using rate=$workingRate")
val actualRate = workingRate
val minBuf = AudioRecord.getMinBufferSize(actualRate, channelConfig, audioFormat)
val bufferSize = maxOf(minBuf, actualRate * 2)
// Step 2: try creating AudioRecord with different sources
val sources = listOf(
MediaRecorder.AudioSource.MIC to "MIC",
MediaRecorder.AudioSource.VOICE_RECOGNITION to "VOICE_RECOGNITION",
MediaRecorder.AudioSource.DEFAULT to "DEFAULT"
)
for ((source, name) in sources) {
sb.appendLine("[AudioTest] Trying source=$name...")
try {
val recorder = AudioRecord(source, actualRate, channelConfig, audioFormat, bufferSize)
sb.appendLine("[AudioTest] state=${recorder.state} (1=INITIALIZED)")
if (recorder.state == AudioRecord.STATE_INITIALIZED) {
recorder.startRecording()
sb.appendLine("[AudioTest] recordingState=${recorder.recordingState} (3=RECORDING)")
// Read 0.5s of audio
val samples = ShortArray(actualRate / 2)
val read = recorder.read(samples, 0, samples.size)
sb.appendLine("[AudioTest] read=$read samples")
if (read > 0) {
// Check if we got actual audio (not silence)
val maxAmp = samples.take(read).maxOf { kotlin.math.abs(it.toInt()) }
val rms = kotlin.math.sqrt(samples.take(read).sumOf { it.toInt() * it.toInt() }.toDouble() / read)
sb.appendLine("[AudioTest] maxAmp=$maxAmp, rms=${rms.toInt()}")
if (maxAmp > 100) {
sb.appendLine("[AudioTest] SUCCESS: audio captured with source=$name")
} else {
sb.appendLine("[AudioTest] WARNING: very quiet (maxAmp=$maxAmp), mic may not work")
}
} else {
sb.appendLine("[AudioTest] FAIL: read returned $read")
}
recorder.stop()
recorder.release()
return sb.toString() // Success — stop trying other sources
} else {
recorder.release()
sb.appendLine("[AudioTest] FAIL: not initialized")
}
} catch (e: Exception) {
sb.appendLine("[AudioTest] EXCEPTION: ${e.message}")
}
}
sb.appendLine("[AudioTest] ALL SOURCES FAILED")
return sb.toString()
}
}

View File

@ -0,0 +1,441 @@
package com.kazeia.ui
import android.util.Log
import android.view.View
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.inputmethod.EditorInfo
import android.widget.ScrollView
import android.widget.TextView
import android.widget.ArrayAdapter
import android.widget.Spinner
import android.widget.AdapterView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.kazeia.core.ChatMessage
import com.kazeia.core.PipelineState
import com.kazeia.databinding.ActivityChatBinding
import com.kazeia.R
// Unity disabled for now
// import com.kazeia.avatar.AvatarActivity
import com.kazeia.service.KazeiaService
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ChatActivity : AppCompatActivity() {
companion object {
private const val PERMISSION_REQUEST_CODE = 1
}
private lateinit var binding: ActivityChatBinding
private lateinit var chatAdapter: ChatAdapter
private var kazeiaService: KazeiaService? = null
private var bound = false
private var serviceStarted = false
private var tvLogs: TextView? = null
private var svLogs: ScrollView? = null
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.FRANCE)
private var resourceMonitor: ResourceMonitor? = null
private var monitoringJob: kotlinx.coroutines.Job? = null
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
Log.i("ChatActivity", "Service connected!")
val serviceBinder = binder as KazeiaService.KazeiaBinder
kazeiaService = serviceBinder.getService()
bound = true
observeService()
appendLog("Service connecte")
}
override fun onServiceDisconnected(name: ComponentName?) {
kazeiaService = null
bound = false
appendLog("Service deconnecte")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityChatBinding.inflate(layoutInflater)
setContentView(binding.root)
// Keep screen on while app is active
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Logs panel
tvLogs = findViewById(R.id.tvLogs)
svLogs = findViewById(R.id.svLogs)
setupWindowInsets()
setupRecyclerView()
setupInputBar()
setupVoiceSelector()
setupResourceMonitoring()
setupQuitButton()
bindToService()
appendLog("Kazeia prêt")
// Audio test in background — writes directly to file
val extDir = getExternalFilesDir(null)
Thread {
try {
val result = AudioTest.testMicrophone()
java.io.File(extDir, "audio_test.txt").writeText(result)
} catch (e: Exception) {
java.io.File(extDir, "audio_test.txt").writeText("CRASH: ${e.message}\n${e.stackTraceToString()}")
}
}.start()
}
private fun setupWindowInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val bottomInset = maxOf(imeInsets.bottom, systemBars.bottom)
view.updatePadding(
top = systemBars.top,
bottom = bottomInset
)
if (imeInsets.bottom > 0) {
val messages = kazeiaService?.messages?.value
if (messages != null && messages.isNotEmpty()) {
binding.rvMessages.post {
binding.rvMessages.smoothScrollToPosition(messages.size - 1)
}
}
}
WindowInsetsCompat.CONSUMED
}
}
private fun setupRecyclerView() {
chatAdapter = ChatAdapter()
binding.rvMessages.apply {
layoutManager = LinearLayoutManager(this@ChatActivity).apply {
stackFromEnd = true
}
adapter = chatAdapter
// Tap anywhere in chat to interrupt TTS
setOnClickListener { interruptTts() }
}
// Also on the root chat area
binding.rvMessages.setOnTouchListener { _, event ->
if (event.action == android.view.MotionEvent.ACTION_DOWN) {
if (kazeiaService?.pipelineState?.value is PipelineState.Speaking) {
interruptTts()
}
}
false // don't consume the event
}
}
private fun interruptTts() {
kazeiaService?.interruptTts()
}
private fun setupInputBar() {
binding.btnSend.setOnClickListener {
ensureServiceStarted()
sendMessage()
}
binding.etMessage.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) {
ensureServiceStarted()
sendMessage()
true
} else false
}
binding.btnMic.setOnClickListener {
ensureServiceStarted()
if (kazeiaService != null) {
kazeiaService?.toggleListening()
} else {
binding.tvStatus.text = "Service en cours de demarrage..."
}
}
}
private val voiceFiles = listOf(
"damien.wav", "elodie.wav", "jerome.wav", "richard.wav",
"amir.wav", "didier.wav", "sid.wav", "zelda.wav"
)
private val voiceNames = listOf(
"Damien", "Elodie", "Jerome", "Richard",
"Amir", "Didier", "Sid", "Zelda"
)
private fun setupResourceMonitoring() {
val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu)
val graphGpu = findViewById<MiniGraphView>(R.id.graphGpu)
val graphNpu = findViewById<MiniGraphView>(R.id.graphNpu)
val graphRam = findViewById<MiniGraphView>(R.id.graphRam)
resourceMonitor = ResourceMonitor(this)
val ramTotal = resourceMonitor!!.snapshot().ramTotalMb.toFloat()
graphCpu?.configure("CPU", "%", 100f, android.graphics.Color.parseColor("#4CAF50"))
graphGpu?.configure("GPU", "%", 100f, android.graphics.Color.parseColor("#2196F3"))
graphNpu?.configure("AI", "%", 100f, android.graphics.Color.parseColor("#FF9800"))
graphRam?.configure("RAM", "MB", if (ramTotal > 0) ramTotal else 16000f, android.graphics.Color.parseColor("#E91E63"))
monitoringJob = lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
val snap = resourceMonitor!!.snapshot()
graphCpu?.addValue(snap.cpuPercent)
graphGpu?.addValue(if (snap.gpuPercent >= 0) snap.gpuPercent else 0f)
// AI workload: show which AI component is active
val workload = kazeiaService?.aiWorkload?.value
val aiPercent = when {
workload?.sttActive == true -> 100f
workload?.llmActive == true -> 100f
workload?.ttsActive == true -> 100f
else -> 0f
}
graphNpu?.addValue(aiPercent)
graphRam?.addValue(snap.ramUsedMb.toFloat())
kotlinx.coroutines.delay(1000)
}
}
}
}
private var debugPanelVisible = false
private fun setupQuitButton() {
// Debug toggle button
findViewById<android.widget.ImageButton>(R.id.btnDebugToggle)?.setOnClickListener {
debugPanelVisible = !debugPanelVisible
setDebugPanelVisible(debugPanelVisible)
}
// Quit button
findViewById<android.widget.ImageButton>(R.id.btnQuit)?.setOnClickListener {
appendLog("Quitting Kazeia...")
try {
stopService(Intent(this, KazeiaService::class.java))
if (bound) { unbindService(serviceConnection); bound = false }
} catch (_: Exception) {}
finishAffinity()
android.os.Process.killProcess(android.os.Process.myPid())
}
}
private fun setupVoiceSelector() {
val spinner = findViewById<Spinner>(R.id.spinnerVoice) ?: return
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, voiceNames)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: android.view.View?, pos: Int, id: Long) {
val voicePath = "${com.kazeia.KazeiaApplication.MODELS_DIR}/../voix/${voiceFiles[pos]}"
kazeiaService?.setVoice(voicePath)
appendLog("Voix: ${voiceNames[pos]}")
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun sendMessage() {
val text = binding.etMessage.text?.toString()?.trim() ?: return
if (text.isEmpty()) return
binding.etMessage.text?.clear()
kazeiaService?.processTextInput(text)
appendLog(">> $text")
}
private fun bindToService() {
if (bound) return
val intent = Intent(this, KazeiaService::class.java)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun ensureServiceStarted() {
if (!bound) {
bindToService()
}
}
private fun observeService() {
val service = kazeiaService ?: return
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
service.messages.collect { messages ->
// Filter out system messages from chat UI
val userMessages = messages.filter { it.role != ChatMessage.Role.SYSTEM }
chatAdapter.submitList(userMessages) {
if (userMessages.isNotEmpty()) {
binding.rvMessages.smoothScrollToPosition(userMessages.size - 1)
}
}
// All messages (including system) go to debug log
if (messages.isNotEmpty()) {
val last = messages.last()
val prefix = when (last.role) {
ChatMessage.Role.PATIENT -> "Patient"
ChatMessage.Role.KAZEIA -> "Kazeia"
ChatMessage.Role.SYSTEM -> "Sys"
}
appendLog("[$prefix] ${last.text}")
}
}
}
launch {
service.pipelineState.collect { state ->
updateStatus(state)
}
}
var lastSvcLogCount = 0
launch {
service.logs.collect { logList ->
if (logList.size > lastSvcLogCount) {
val newLogs = logList.subList(lastSvcLogCount, logList.size)
for (log in newLogs) appendLog("[Svc] $log")
lastSvcLogCount = logList.size
}
}
}
launch {
service.debugMode.collect { debug ->
setDebugPanelVisible(debug)
}
}
}
}
}
private fun updateStatus(state: PipelineState) {
val statusText = when (state) {
is PipelineState.Idle -> getString(R.string.status_idle)
is PipelineState.Listening -> getString(R.string.status_listening)
is PipelineState.SpeechDetected -> "Parole detectee..."
is PipelineState.Transcribing -> getString(R.string.status_transcribing)
is PipelineState.Transcribed -> getString(R.string.status_transcribing)
is PipelineState.Thinking -> getString(R.string.status_thinking)
is PipelineState.TokenGenerated -> getString(R.string.status_thinking)
is PipelineState.ResponseReady -> getString(R.string.status_idle)
is PipelineState.Speaking -> getString(R.string.status_speaking)
is PipelineState.Error -> "${getString(R.string.status_error)}: ${state.message}"
}
binding.tvStatus.text = statusText
// Mic button color is driven by isListening, not pipeline state
val listening = kazeiaService?.isListening?.value ?: false
binding.btnMic.setBackgroundResource(
if (listening) R.drawable.bg_mic_listening else R.drawable.bg_mic_button
)
// Log state changes
if (state !is PipelineState.TokenGenerated) {
appendLog("State: $statusText")
}
}
private fun setDebugPanelVisible(visible: Boolean) {
runOnUiThread {
val visibility = if (visible) View.VISIBLE else View.GONE
findViewById<View>(R.id.debugPanel)?.visibility = visibility
findViewById<View>(R.id.debugDivider)?.visibility = visibility
}
}
private var logFile: java.io.FileWriter? = null
private fun appendLog(msg: String) {
val time = timeFormat.format(Date())
val line = "$time $msg"
// Write to file for adb access
try {
if (logFile == null) {
val f = java.io.File(getExternalFilesDir(null), "debug_log.txt")
logFile = java.io.FileWriter(f, false)
}
logFile?.appendLine(line)
logFile?.flush()
} catch (_: Exception) {}
// Write to UI
val tv = tvLogs ?: return
val sv = svLogs ?: return
runOnUiThread {
tv.append("$line\n")
sv.post { sv.fullScroll(ScrollView.FOCUS_DOWN) }
}
}
private fun checkAndRequestPermissions() {
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
val needed = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), PERMISSION_REQUEST_CODE)
} else {
startAndBindService()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) {
startAndBindService()
}
}
private fun startAndBindService() {
if (serviceStarted) return
serviceStarted = true
Log.i("ChatActivity", "Starting and binding service")
appendLog("Demarrage du service...")
val intent = Intent(this, KazeiaService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
override fun onDestroy() {
super.onDestroy()
if (bound) {
unbindService(serviceConnection)
bound = false
}
}
}

View File

@ -0,0 +1,68 @@
package com.kazeia.ui
import android.view.Gravity
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.kazeia.R
import com.kazeia.core.ChatMessage
import com.kazeia.databinding.ItemMessageBinding
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ChatAdapter : ListAdapter<ChatMessage, ChatAdapter.MessageViewHolder>(DiffCallback) {
private val timeFormat = SimpleDateFormat("HH:mm", Locale.FRANCE)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
val binding = ItemMessageBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return MessageViewHolder(binding)
}
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class MessageViewHolder(
private val binding: ItemMessageBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(message: ChatMessage) {
binding.tvMessage.text = message.text
binding.tvTime.text = timeFormat.format(Date(message.timestamp))
val container = binding.bubbleContainer
val params = container.layoutParams as FrameLayout.LayoutParams
when (message.role) {
ChatMessage.Role.PATIENT -> {
binding.tvSender.text = "Vous"
container.setBackgroundResource(R.drawable.bg_bubble_patient)
params.gravity = Gravity.END
}
ChatMessage.Role.KAZEIA -> {
binding.tvSender.text = "Kazeia"
container.setBackgroundResource(R.drawable.bg_bubble_kazeia)
params.gravity = Gravity.START
}
ChatMessage.Role.SYSTEM -> {
binding.tvSender.text = "Système"
container.setBackgroundResource(R.drawable.bg_bubble_kazeia)
params.gravity = Gravity.CENTER
}
}
container.layoutParams = params
}
}
private object DiffCallback : DiffUtil.ItemCallback<ChatMessage>() {
override fun areItemsTheSame(old: ChatMessage, new: ChatMessage) = old.id == new.id
override fun areContentsTheSame(old: ChatMessage, new: ChatMessage) = old == new
}
}

View File

@ -0,0 +1,117 @@
package com.kazeia.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
class MiniGraphView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val maxPoints = 60
private val values = mutableListOf<Float>()
private var maxValue = 100f
private var label = ""
private var unit = "%"
private var graphColor = Color.GREEN
private var lastValue = 0f
private val linePaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 2f
isAntiAlias = true
}
private val fillPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private val textPaint = Paint().apply {
color = Color.WHITE
textSize = 28f
isAntiAlias = true
}
private val labelPaint = Paint().apply {
color = Color.parseColor("#AAAAAA")
textSize = 22f
isAntiAlias = true
}
private val bgPaint = Paint().apply {
color = Color.parseColor("#2A2A2A")
style = Paint.Style.FILL
}
private val gridPaint = Paint().apply {
color = Color.parseColor("#3A3A3A")
style = Paint.Style.STROKE
strokeWidth = 1f
}
fun configure(label: String, unit: String = "%", maxValue: Float = 100f, color: Int = Color.GREEN) {
this.label = label
this.unit = unit
this.maxValue = maxValue
this.graphColor = color
linePaint.color = color
fillPaint.color = Color.argb(40, Color.red(color), Color.green(color), Color.blue(color))
}
fun addValue(value: Float) {
lastValue = value
values.add(value.coerceIn(0f, maxValue))
if (values.size > maxPoints) values.removeAt(0)
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width.toFloat()
val h = height.toFloat()
val padding = 4f
// Background
canvas.drawRect(0f, 0f, w, h, bgPaint)
// Grid lines
for (i in 1..3) {
val y = h * i / 4
canvas.drawLine(0f, y, w, y, gridPaint)
}
// Graph
if (values.size >= 2) {
val path = Path()
val fillPath = Path()
val step = (w - 2 * padding) / (maxPoints - 1)
val startX = w - padding - (values.size - 1) * step
val startY = h - padding - (values[0] / maxValue * (h - 2 * padding))
path.moveTo(startX, startY)
fillPath.moveTo(startX, h - padding)
fillPath.lineTo(startX, startY)
for (i in 1 until values.size) {
val x = startX + i * step
val y = h - padding - (values[i] / maxValue * (h - 2 * padding))
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
fillPath.lineTo(startX + (values.size - 1) * step, h - padding)
fillPath.close()
canvas.drawPath(fillPath, fillPaint)
canvas.drawPath(path, linePaint)
}
// Label (top-left)
canvas.drawText(label, 6f, 20f, labelPaint)
// Current value (top-right)
val valueText = if (lastValue < 0) "N/A" else "${lastValue.toInt()}$unit"
val tw = textPaint.measureText(valueText)
canvas.drawText(valueText, w - tw - 6f, 24f, textPaint)
}
}

View File

@ -0,0 +1,145 @@
package com.kazeia.ui
import android.app.ActivityManager
import android.content.Context
import java.io.File
data class ResourceSnapshot(
val cpuPercent: Float,
val gpuPercent: Float,
val npuPercent: Float,
val ramUsedMb: Long,
val ramTotalMb: Long
)
class ResourceMonitor(private val context: Context) {
private var prevTotal = 0L
private var prevIdle = 0L
private var prevGpuBusy = 0L
private var prevGpuTotal = 0L
private var hasRoot = false
init {
// Test root access once
hasRoot = try {
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", "id"))
val result = p.inputStream.bufferedReader().readText()
p.waitFor()
result.contains("uid=0")
} catch (_: Exception) { false }
}
fun snapshot(): ResourceSnapshot {
return ResourceSnapshot(
cpuPercent = readCpu(),
gpuPercent = readGpu(),
npuPercent = readNpu(),
ramUsedMb = readRamUsed(),
ramTotalMb = readRamTotal()
)
}
private fun readCpu(): Float {
try {
val parts = File("/proc/stat").bufferedReader().readLine().trim().split("\\s+".toRegex())
if (parts.size < 8) return 0f
val user = parts[1].toLong()
val nice = parts[2].toLong()
val system = parts[3].toLong()
val idle = parts[4].toLong()
val iowait = parts[5].toLong()
val irq = parts[6].toLong()
val softirq = parts[7].toLong()
val total = user + nice + system + idle + iowait + irq + softirq
val idleAll = idle + iowait
val dt = total - prevTotal
val di = idleAll - prevIdle
prevTotal = total
prevIdle = idleAll
return if (dt > 0) ((dt - di).toFloat() / dt * 100f).coerceIn(0f, 100f) else 0f
} catch (_: Exception) { return 0f }
}
private fun readGpu(): Float {
// Try direct read first (works on some devices)
try {
val content = File("/sys/class/kgsl/kgsl-3d0/gpubusy").readText().trim()
val parts = content.split("\\s+".toRegex())
if (parts.size >= 2) {
val busy = parts[0].toLong()
val total = parts[1].toLong()
val db = busy - prevGpuBusy
val dt = total - prevGpuTotal
prevGpuBusy = busy
prevGpuTotal = total
if (dt > 0) return (db * 100f / dt).coerceIn(0f, 100f)
}
} catch (_: Exception) {}
// Try with root
if (hasRoot) {
try {
val content = execRoot("cat /sys/class/kgsl/kgsl-3d0/gpu_busy_percentage").trim()
val pct = content.replace("%", "").trim().toFloatOrNull()
if (pct != null) return pct.coerceIn(0f, 100f)
} catch (_: Exception) {}
}
return -1f
}
private fun readNpu(): Float {
// NPU doesn't have a standard busy metric
// Use CDSP (compute DSP) load as proxy if available
if (hasRoot) {
try {
// Check if CDSP is active by reading vote count
val vote = execRoot("cat /sys/bus/platform/devices/soc:qcom,msm-cdsp-rm/cdsp_rm/cpu_vote 2>/dev/null").trim()
if (vote.isNotEmpty()) {
val v = vote.toIntOrNull() ?: 0
return if (v > 0) 100f else 0f
}
} catch (_: Exception) {}
try {
// Alternative: check fastrpc activity
val stat = execRoot("cat /proc/fastrpc 2>/dev/null || echo none").trim()
if (stat != "none" && stat.isNotEmpty()) return 50f
} catch (_: Exception) {}
}
return -1f
}
private fun readRamUsed(): Long {
try {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val mi = ActivityManager.MemoryInfo()
am.getMemoryInfo(mi)
return (mi.totalMem - mi.availMem) / (1024 * 1024)
} catch (_: Exception) { return 0 }
}
private fun readRamTotal(): Long {
try {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val mi = ActivityManager.MemoryInfo()
am.getMemoryInfo(mi)
return mi.totalMem / (1024 * 1024)
} catch (_: Exception) { return 0 }
}
private fun execRoot(cmd: String): String {
return try {
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
val result = p.inputStream.bufferedReader().readText()
p.waitFor()
result
} catch (_: Exception) { "" }
}
}

View File

@ -0,0 +1,142 @@
package com.kazeia.ui
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.kazeia.R
// Unity disabled — using ChatActivity directly
// import com.kazeia.avatar.AvatarActivity
import com.kazeia.service.KazeiaService
import kotlinx.coroutines.launch
/**
* Splash screen: loads ML models + waits for Unity lib preload.
* Then launches AvatarActivity (main screen with avatar + chat overlay).
*/
class SplashActivity : AppCompatActivity() {
companion object {
private const val TAG = "SplashActivity"
private const val PERMISSION_REQUEST_CODE = 1
}
private var kazeiaService: KazeiaService? = null
private var bound = false
private lateinit var progressBar: ProgressBar
private lateinit var tvStep: TextView
private lateinit var tvPercent: TextView
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
Log.i(TAG, "Service connected")
val serviceBinder = binder as KazeiaService.KazeiaBinder
kazeiaService = serviceBinder.getService()
bound = true
observeLoading()
}
override fun onServiceDisconnected(name: ComponentName?) {
kazeiaService = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
progressBar = findViewById(R.id.progressBar)
tvStep = findViewById(R.id.tvLoadingStep)
tvPercent = findViewById(R.id.tvPercent)
checkAndRequestPermissions()
}
private fun checkAndRequestPermissions() {
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
val needed = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), PERMISSION_REQUEST_CODE)
} else {
startAndBindService()
}
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) {
startAndBindService()
}
}
private fun startAndBindService() {
Log.i(TAG, "Starting service")
tvStep.text = "Demarrage du service…"
val intent = Intent(this, KazeiaService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun observeLoading() {
val service = kazeiaService ?: return
lifecycleScope.launch {
service.loadingState.collect { state ->
progressBar.progress = state.progress
tvStep.text = state.step
tvPercent.text = "${state.progress}%"
if (state.done) {
Log.i(TAG, "Loading complete, launching AvatarActivity")
launchMain()
}
}
}
}
/**
* Launch AvatarActivity as the main screen (Unity + chat overlay).
* Unity's native libs were pre-loaded by KazeiaApplication during splash,
* so the Unity engine startup inside AvatarActivity will be faster.
*/
private fun launchMain() {
val intent = Intent(this, ChatActivity::class.java)
startActivity(intent)
finish()
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
override fun onDestroy() {
if (bound) {
unbindService(serviceConnection)
bound = false
}
super.onDestroy()
}
}

View File

@ -0,0 +1,118 @@
package com.kazeia.vad
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.util.Log
import com.kazeia.core.VadEngine
import java.nio.FloatBuffer
import java.nio.LongBuffer
class SileroVadEngine : VadEngine {
companion object {
private const val TAG = "SileroVAD"
private const val THRESHOLD = 0.5f
private const val SAMPLE_RATE = 16000L
private const val WINDOW_SIZE = 512
}
private var ortEnv: OrtEnvironment? = null
private var session: OrtSession? = null
private var state: Array<FloatArray>? = null
private var loaded = false
override fun load(context: Context) {
try {
ortEnv = OrtEnvironment.getEnvironment()
// Load silero_vad.onnx from assets
val modelBytes = context.assets.open("silero_vad.onnx").readBytes()
session = ortEnv!!.createSession(modelBytes)
resetState()
loaded = true
Log.i(TAG, "Silero VAD loaded")
} catch (e: Exception) {
Log.e(TAG, "Failed to load Silero VAD", e)
loaded = false
}
}
override fun isLoaded(): Boolean = loaded
private var debugLogCount = 0
override fun isSpeech(frame: ShortArray): Boolean {
if (!loaded || session == null) return false
try {
val floatFrame = FloatArray(frame.size) { frame[it] / 32768f }
val inputTensor = OnnxTensor.createTensor(
ortEnv,
FloatBuffer.wrap(floatFrame),
longArrayOf(1, frame.size.toLong())
)
val stateArray = state ?: return false
val stateTensor = OnnxTensor.createTensor(
ortEnv,
FloatBuffer.wrap(stateArray.flatMap { it.toList() }.toFloatArray()),
longArrayOf(2, 1, 128)
)
val srTensor = OnnxTensor.createTensor(
ortEnv, longArrayOf(SAMPLE_RATE)
)
val inputs = mapOf(
"input" to inputTensor,
"state" to stateTensor,
"sr" to srTensor
)
val results = session!!.run(inputs)
// Output "output": shape [batch, 1] = float[][]
val outputTensor = results.get("output").get()
val probability = (outputTensor.value as Array<FloatArray>)[0][0]
// State "stateN": shape [2, batch, 128] = float[][][]
val stateTensor2 = results.get("stateN").get()
@Suppress("UNCHECKED_CAST")
val state3d = stateTensor2.value as Array<Array<FloatArray>>
state = Array(state3d.size) { i -> state3d[i][0].copyOf() }
inputTensor.close()
stateTensor.close()
srTensor.close()
results.close()
if (debugLogCount < 5 || (debugLogCount % 100 == 0) || probability > THRESHOLD) {
Log.d(TAG, "VAD prob=$probability (frame ${debugLogCount})")
}
debugLogCount++
return probability > THRESHOLD
} catch (e: Exception) {
if (debugLogCount < 10) {
Log.e(TAG, "VAD inference error", e)
}
debugLogCount++
return false
}
}
override fun resetState() {
state = Array(2) { FloatArray(128) }
}
override fun release() {
session?.close()
ortEnv?.close()
session = null
ortEnv = null
loaded = false
}
}

View File

@ -0,0 +1,43 @@
cmake_minimum_required(VERSION 3.22)
project(kazeia-jni)
set(JNILIBS_DIR ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
# --- Genie JNI bridge ---
add_library(genie_jni SHARED genie_jni.cpp)
add_library(Genie SHARED IMPORTED)
set_target_properties(Genie PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libGenie.so)
target_link_libraries(genie_jni Genie android log)
target_compile_options(genie_jni PRIVATE -std=c++17 -O2)
# --- Whisper JNI bridge ---
add_library(whisper_jni SHARED whisper_jni.cpp)
# Prebuilt whisper + ggml libs
add_library(whisper SHARED IMPORTED)
set_target_properties(whisper PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libwhisper.so)
add_library(ggml SHARED IMPORTED)
set_target_properties(ggml PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml.so)
add_library(ggml-base SHARED IMPORTED)
set_target_properties(ggml-base PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml-base.so)
add_library(ggml-cpu SHARED IMPORTED)
set_target_properties(ggml-cpu PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml-cpu.so)
# Include whisper.h
target_include_directories(whisper_jni PRIVATE
/opt/Kazeia/whisper.cpp/include
/opt/Kazeia/whisper.cpp/ggml/include
)
target_link_libraries(whisper_jni whisper ggml ggml-base ggml-cpu android log)
target_compile_options(whisper_jni PRIVATE -std=c++17 -O2)
# --- Mel Extractor (HuggingFace-compatible, no whisper.cpp dependency) ---
add_library(mel_extractor SHARED mel_extractor.cpp)
target_link_libraries(mel_extractor android log)
target_compile_options(mel_extractor PRIVATE -std=c++17 -O2)

View File

@ -0,0 +1,201 @@
#include <jni.h>
#include <string>
#include <android/log.h>
#define TAG "GenieJNI"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
// Genie C API declarations (from libGenie.so)
extern "C" {
// Opaque types
typedef void* GenieDialogConfig;
typedef void* GenieDialog;
typedef void* GenieTokenizer;
typedef void* GenieSampler;
// Version
int Genie_getApiMajorVersion();
int Genie_getApiMinorVersion();
int Genie_getApiPatchVersion();
// DialogConfig
GenieDialogConfig GenieDialogConfig_createFromJson(const char* jsonPath);
void GenieDialogConfig_free(GenieDialogConfig config);
// Dialog
GenieDialog GenieDialog_create(GenieDialogConfig config);
void GenieDialog_free(GenieDialog dialog);
const char* GenieDialog_query(GenieDialog dialog, const char* prompt);
void GenieDialog_setStopSequence(GenieDialog dialog, const char* stopSeq);
void GenieDialog_reset(GenieDialog dialog);
void GenieDialog_signal(GenieDialog dialog);
GenieTokenizer GenieDialog_getTokenizer(GenieDialog dialog);
GenieSampler GenieDialog_getSampler(GenieDialog dialog);
// Sampler callback
typedef bool (*GenieSamplerCallback)(const char* token, void* userData);
void GenieSampler_registerUserDataCallback(
GenieSampler sampler,
GenieSamplerCallback callback,
void* userData
);
// Tokenizer
const char* GenieTokenizer_decode(GenieTokenizer tokenizer, const int* tokens, int numTokens);
int* GenieTokenizer_encode(GenieTokenizer tokenizer, const char* text, int* numTokens);
}
// Check if a pointer looks like a valid heap pointer (not an error code)
// Genie SDK returns small negative int values as error codes (e.g. -5 = 0xfffffffb)
// On ARM64 Android, valid heap pointers are always > 0x100000000 (above 4GB)
static bool isValidPointer(void* ptr) {
uintptr_t addr = reinterpret_cast<uintptr_t>(ptr);
if (ptr == nullptr) return false;
// Any value that fits in 32 bits is likely an error code, not a heap pointer
if (addr <= 0xFFFFFFFFULL) return false;
return true;
}
// Callback context for token streaming
struct CallbackContext {
JNIEnv* env;
jobject callback;
jmethodID onTokenMethod;
std::string fullResponse;
bool shouldStop;
};
static bool samplerCallback(const char* token, void* userData) {
auto* ctx = reinterpret_cast<CallbackContext*>(userData);
if (ctx->shouldStop || token == nullptr) return false;
ctx->fullResponse += token;
if (ctx->callback != nullptr) {
JNIEnv* env = ctx->env;
jstring jToken = env->NewStringUTF(token);
jboolean continueGen = env->CallBooleanMethod(
ctx->callback, ctx->onTokenMethod, jToken
);
env->DeleteLocalRef(jToken);
if (!continueGen) {
ctx->shouldStop = true;
return false;
}
}
return true;
}
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_kazeia_llm_GenieJni_createDialog(JNIEnv* env, jobject, jstring configPath) {
const char* path = env->GetStringUTFChars(configPath, nullptr);
LOGI("Creating dialog from config: %s", path);
GenieDialogConfig config = GenieDialogConfig_createFromJson(path);
env->ReleaseStringUTFChars(configPath, path);
if (!isValidPointer(config)) {
LOGE("Failed to create dialog config (returned %p, likely error code %ld)",
config, reinterpret_cast<long>(config));
return 0;
}
LOGI("Dialog config created: %p", config);
GenieDialog dialog = GenieDialog_create(config);
GenieDialogConfig_free(config);
if (!isValidPointer(dialog)) {
LOGE("Failed to create dialog (returned %p, likely error code %ld)",
dialog, reinterpret_cast<long>(dialog));
return 0;
}
LOGI("Dialog created successfully: %p", dialog);
return reinterpret_cast<jlong>(dialog);
}
JNIEXPORT jstring JNICALL
Java_com_kazeia_llm_GenieJni_query(
JNIEnv* env, jobject, jlong dialogHandle, jstring prompt, jobject callback
) {
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
if (!isValidPointer(dialog)) {
LOGE("Invalid dialog handle: %p", dialog);
return env->NewStringUTF("[Erreur: modèle LLM non chargé]");
}
const char* promptStr = env->GetStringUTFChars(prompt, nullptr);
CallbackContext ctx;
ctx.env = env;
ctx.callback = callback;
ctx.shouldStop = false;
if (callback != nullptr) {
jclass cbClass = env->GetObjectClass(callback);
ctx.onTokenMethod = env->GetMethodID(
cbClass, "onToken", "(Ljava/lang/String;)Z"
);
// Register the sampler callback
GenieSampler sampler = GenieDialog_getSampler(dialog);
if (isValidPointer(sampler)) {
GenieSampler_registerUserDataCallback(sampler, samplerCallback, &ctx);
} else {
LOGE("Invalid sampler pointer: %p", sampler);
}
}
LOGI("Query: %.80s...", promptStr);
const char* response = GenieDialog_query(dialog, promptStr);
env->ReleaseStringUTFChars(prompt, promptStr);
// Use callback accumulated response if available, otherwise use direct response
std::string result;
if (!ctx.fullResponse.empty()) {
result = ctx.fullResponse;
} else if (response != nullptr) {
result = response;
}
LOGI("Response length: %zu chars", result.size());
return env->NewStringUTF(result.c_str());
}
JNIEXPORT void JNICALL
Java_com_kazeia_llm_GenieJni_setStopSequence(
JNIEnv* env, jobject, jlong dialogHandle, jstring stopSequence
) {
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
if (!isValidPointer(dialog)) return;
const char* seq = env->GetStringUTFChars(stopSequence, nullptr);
GenieDialog_setStopSequence(dialog, seq);
env->ReleaseStringUTFChars(stopSequence, seq);
}
JNIEXPORT void JNICALL
Java_com_kazeia_llm_GenieJni_freeDialog(JNIEnv*, jobject, jlong dialogHandle) {
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
if (isValidPointer(dialog)) {
GenieDialog_free(dialog);
LOGI("Dialog freed");
}
}
JNIEXPORT jstring JNICALL
Java_com_kazeia_llm_GenieJni_getVersion(JNIEnv* env, jobject) {
int major = Genie_getApiMajorVersion();
int minor = Genie_getApiMinorVersion();
int patch = Genie_getApiPatchVersion();
std::string version = std::to_string(major) + "." +
std::to_string(minor) + "." +
std::to_string(patch);
return env->NewStringUTF(version.c_str());
}
} // extern "C"

View File

@ -0,0 +1,202 @@
#include <jni.h>
#include <cmath>
#include <vector>
#include <algorithm>
#include <numeric>
#include <android/log.h>
#define TAG "MelExtractor"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
// Whisper constants
static constexpr int SAMPLE_RATE = 16000;
static constexpr int N_FFT = 400;
static constexpr int HOP_LENGTH = 160;
static constexpr int N_MELS = 80;
static constexpr int CHUNK_LENGTH = 30;
static constexpr int N_FRAMES = 3000; // CHUNK_LENGTH * SAMPLE_RATE / HOP_LENGTH
// Pre-loaded mel filters [N_MELS * (N_FFT/2+1)] = [80 * 201]
static std::vector<float> g_mel_filters;
static constexpr int FFT_SIZE = N_FFT / 2 + 1; // 201
// Cooley-Tukey radix-2 FFT (in-place, size must be power of 2)
static void fft(float* real, float* imag, int n) {
// Bit-reversal permutation
int j = 0;
for (int i = 1; i < n; i++) {
int bit = n >> 1;
while (j & bit) { j ^= bit; bit >>= 1; }
j ^= bit;
if (i < j) {
std::swap(real[i], real[j]);
std::swap(imag[i], imag[j]);
}
}
// Butterfly
for (int len = 2; len <= n; len <<= 1) {
int half = len / 2;
double angle = -2.0 * M_PI / len;
float wR = (float)cos(angle);
float wI = (float)sin(angle);
for (int i = 0; i < n; i += len) {
float curR = 1.0f, curI = 0.0f;
for (int k = 0; k < half; k++) {
float tR = curR * real[i+k+half] - curI * imag[i+k+half];
float tI = curR * imag[i+k+half] + curI * real[i+k+half];
real[i+k+half] = real[i+k] - tR;
imag[i+k+half] = imag[i+k] - tI;
real[i+k] += tR;
imag[i+k] += tI;
float newR = curR * wR - curI * wI;
curI = curR * wI + curI * wR;
curR = newR;
}
}
}
}
extern "C" {
/**
* Load mel filter bank from a flat float array [N_MELS * FFT_SIZE].
*/
JNIEXPORT void JNICALL
Java_com_kazeia_stt_MelExtractor_loadFilters(JNIEnv* env, jobject, jfloatArray filters) {
jint len = env->GetArrayLength(filters);
g_mel_filters.resize(len);
env->GetFloatArrayRegion(filters, 0, len, g_mel_filters.data());
LOGI("Mel filters loaded: %d values (%d mels x %d bins)", len, N_MELS, FFT_SIZE);
}
/**
* Compute mel spectrogram from PCM16 audio.
* Exact replica of HuggingFace WhisperFeatureExtractor:
* 1. Reflect-pad audio by N_FFT/2
* 2. STFT with periodic Hann window (N_FFT=400, hop=160)
* 3. Power spectrum mel filter bank
* 4. log10(max(mel, 1e-10))
* 5. clamp to max - 8.0
* 6. (x + 4.0) / 4.0
* Returns float[N_MELS * N_FRAMES] in mel-major order [mel][frame].
*/
JNIEXPORT jfloatArray JNICALL
Java_com_kazeia_stt_MelExtractor_computeMel(JNIEnv* env, jobject, jshortArray audioData) {
if (g_mel_filters.empty()) {
LOGI("ERROR: mel filters not loaded");
return nullptr;
}
jint audioLen = env->GetArrayLength(audioData);
jshort* audioPtr = env->GetShortArrayElements(audioData, nullptr);
// Convert to float (no gain — raw audio)
int targetLen = CHUNK_LENGTH * SAMPLE_RATE; // 480000
std::vector<float> audio(targetLen, 0.0f);
for (int i = 0; i < std::min(audioLen, targetLen); i++) {
audio[i] = static_cast<float>(audioPtr[i]) / 32768.0f;
}
env->ReleaseShortArrayElements(audioData, audioPtr, 0);
LOGI("Audio: %d samples, peak=%.3f", audioLen,
*std::max_element(audio.begin(), audio.end(),
[](float a, float b){ return std::abs(a) < std::abs(b); }));
// Reflect-pad by N_FFT/2 = 200 on each side
int padAmount = N_FFT / 2;
int paddedLen = targetLen + 2 * padAmount;
std::vector<float> padded(paddedLen, 0.0f);
// Left reflect pad
for (int i = 0; i < padAmount; i++) {
int srcIdx = std::min(i + 1, targetLen - 1);
padded[padAmount - 1 - i] = audio[srcIdx];
}
// Copy audio
std::copy(audio.begin(), audio.end(), padded.begin() + padAmount);
// Right reflect pad
for (int i = 0; i < padAmount; i++) {
int srcIdx = std::max(targetLen - 2 - i, 0);
padded[padAmount + targetLen + i] = audio[srcIdx];
}
// Periodic Hann window
std::vector<float> window(N_FFT);
for (int i = 0; i < N_FFT; i++) {
window[i] = 0.5f * (1.0f - cosf(2.0f * M_PI * i / N_FFT));
}
// STFT + mel filter bank + log10
// FFT size = 512 (next power of 2 >= N_FFT=400), but we only use first 201 bins
constexpr int FFT_N = 512;
std::vector<float> mel_spec(N_MELS * N_FRAMES, 0.0f);
std::vector<float> fft_real(FFT_N), fft_imag(FFT_N);
// Pre-compute DFT twiddle factors for N_FFT=400, 201 output bins
std::vector<float> cos_table(FFT_SIZE * N_FFT);
std::vector<float> sin_table(FFT_SIZE * N_FFT);
for (int k = 0; k < FFT_SIZE; k++) {
for (int n = 0; n < N_FFT; n++) {
float angle = -2.0f * M_PI * k * n / N_FFT;
cos_table[k * N_FFT + n] = cosf(angle);
sin_table[k * N_FFT + n] = sinf(angle);
}
}
LOGI("Twiddle factors computed");
for (int frame = 0; frame < N_FRAMES; frame++) {
int offset = frame * HOP_LENGTH;
// Windowed frame
float windowed[N_FFT];
for (int i = 0; i < N_FFT; i++) {
int idx = offset + i;
windowed[i] = (idx < paddedLen) ? padded[idx] * window[i] : 0.0f;
}
// DFT → power spectrum → mel filters (fused)
// Compute 201 DFT bins, apply mel filters, accumulate
float power[FFT_SIZE];
for (int k = 0; k < FFT_SIZE; k++) {
float re = 0.0f, im = 0.0f;
const float* cos_k = &cos_table[k * N_FFT];
const float* sin_k = &sin_table[k * N_FFT];
for (int n = 0; n < N_FFT; n++) {
re += windowed[n] * cos_k[n];
im += windowed[n] * sin_k[n];
}
power[k] = re * re + im * im;
}
// Apply mel filters
for (int mel = 0; mel < N_MELS; mel++) {
float sum = 0.0f;
const float* filt = &g_mel_filters[mel * FFT_SIZE];
for (int k = 0; k < FFT_SIZE; k++) {
sum += filt[k] * power[k];
}
mel_spec[mel * N_FRAMES + frame] = log10f(fmaxf(sum, 1e-10f));
}
if (frame % 500 == 0) {
LOGI("Frame %d/%d", frame, N_FRAMES);
}
}
// Normalize: clamp to max-8, then (x + 4) / 4
float maxVal = *std::max_element(mel_spec.begin(), mel_spec.end());
for (auto& v : mel_spec) {
v = fmaxf(v, maxVal - 8.0f);
v = (v + 4.0f) / 4.0f;
}
LOGI("Mel: range [%.3f, %.3f], mean=%.3f",
*std::min_element(mel_spec.begin(), mel_spec.end()),
maxVal,
std::accumulate(mel_spec.begin(), mel_spec.end(), 0.0f) / mel_spec.size());
jfloatArray result = env->NewFloatArray(N_MELS * N_FRAMES);
env->SetFloatArrayRegion(result, 0, N_MELS * N_FRAMES, mel_spec.data());
return result;
}
} // extern "C"

View File

@ -0,0 +1,156 @@
#include <jni.h>
#include <string>
#include <vector>
#include <android/log.h>
#include "whisper.h"
#define TAG "WhisperJNI"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_kazeia_stt_WhisperJni_initContext(JNIEnv* env, jobject, jstring modelPath) {
const char* path = env->GetStringUTFChars(modelPath, nullptr);
LOGI("Loading Whisper model: %s", path);
struct whisper_context_params cparams = whisper_context_default_params();
struct whisper_context* ctx = whisper_init_from_file_with_params(path, cparams);
env->ReleaseStringUTFChars(modelPath, path);
if (ctx == nullptr) {
LOGE("Failed to init whisper context");
return 0;
}
LOGI("Whisper model loaded successfully");
return reinterpret_cast<jlong>(ctx);
}
JNIEXPORT jstring JNICALL
Java_com_kazeia_stt_WhisperJni_transcribe(
JNIEnv* env, jobject, jlong contextPtr, jshortArray audioData, jstring language
) {
auto* ctx = reinterpret_cast<struct whisper_context*>(contextPtr);
if (ctx == nullptr) {
return env->NewStringUTF("");
}
const char* lang = env->GetStringUTFChars(language, nullptr);
// Get audio data
jint audioLen = env->GetArrayLength(audioData);
jshort* audioPtr = env->GetShortArrayElements(audioData, nullptr);
// Convert short to float and auto-gain normalize
std::vector<float> pcmf32(audioLen);
float maxAbs = 0.0f;
for (int i = 0; i < audioLen; i++) {
float v = static_cast<float>(audioPtr[i]);
if (std::abs(v) > maxAbs) maxAbs = std::abs(v);
}
// Normalize to [-1, 1] range — always auto-gain
float gain = (maxAbs > 10.0f) ? (32768.0f * 0.9f / maxAbs) : 1.0f;
LOGI("Audio max=%.0f, gain=%.2f", maxAbs, gain);
for (int i = 0; i < audioLen; i++) {
pcmf32[i] = static_cast<float>(audioPtr[i]) * gain / 32768.0f;
}
env->ReleaseShortArrayElements(audioData, audioPtr, 0);
// Configure whisper params
struct whisper_full_params params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
params.language = lang;
params.translate = false;
params.no_timestamps = true;
params.single_segment = false;
params.print_special = false;
params.print_progress = false;
params.print_realtime = false;
params.print_timestamps = false;
params.n_threads = 4;
LOGI("Transcribing %d samples (%.1fs) in '%s'...", audioLen,
(float)audioLen / 16000.0f, lang);
int ret = whisper_full(ctx, params, pcmf32.data(), pcmf32.size());
env->ReleaseStringUTFChars(language, lang);
if (ret != 0) {
LOGE("whisper_full failed with code %d", ret);
return env->NewStringUTF("");
}
// Collect all segments
std::string result;
int n_segments = whisper_full_n_segments(ctx);
for (int i = 0; i < n_segments; i++) {
const char* text = whisper_full_get_segment_text(ctx, i);
if (text) {
result += text;
}
}
LOGI("Transcription: '%s' (%d segments)", result.c_str(), n_segments);
return env->NewStringUTF(result.c_str());
}
JNIEXPORT void JNICALL
Java_com_kazeia_stt_WhisperJni_freeContext(JNIEnv*, jobject, jlong contextPtr) {
auto* ctx = reinterpret_cast<struct whisper_context*>(contextPtr);
if (ctx != nullptr) {
whisper_free(ctx);
LOGI("Whisper context freed");
}
}
JNIEXPORT jfloatArray JNICALL
Java_com_kazeia_stt_WhisperJni_computeMel(
JNIEnv* env, jobject, jlong contextPtr, jshortArray audioData
) {
auto* ctx = reinterpret_cast<struct whisper_context*>(contextPtr);
if (ctx == nullptr) return nullptr;
jint audioLen = env->GetArrayLength(audioData);
jshort* audioPtr = env->GetShortArrayElements(audioData, nullptr);
// Convert to float — normalize peak to ~0.5 (match typical Whisper training audio)
std::vector<float> pcmf32(audioLen);
float maxAbs = 0.0f;
for (int i = 0; i < audioLen; i++) {
float v = std::abs(static_cast<float>(audioPtr[i]));
if (v > maxAbs) maxAbs = v;
}
// Target peak: 0.5 (typical audio level in Whisper training data)
float targetPeak = 0.5f;
float currentPeak = maxAbs / 32768.0f;
float gain = (currentPeak > 0.001f) ? (targetPeak / currentPeak) : 1.0f;
for (int i = 0; i < audioLen; i++) {
pcmf32[i] = static_cast<float>(audioPtr[i]) / 32768.0f * gain;
}
env->ReleaseShortArrayElements(audioData, audioPtr, 0);
LOGI("computeMel: %d samples, peak=%.3f, gain=%.2f → target=%.1f", audioLen, currentPeak, gain, targetPeak);
// Compute mel using whisper.cpp
int ret = whisper_pcm_to_mel(ctx, pcmf32.data(), pcmf32.size(), 4);
if (ret != 0) {
LOGE("whisper_pcm_to_mel failed: %d", ret);
return nullptr;
}
// Extract mel data
int n_mels = whisper_model_n_mels(ctx);
int n_len = whisper_get_mel_len(ctx);
const float* melData = whisper_get_mel_data(ctx);
LOGI("Mel computed: %d mels x %d frames", n_mels, n_len);
int totalSize = n_mels * n_len;
jfloatArray result = env->NewFloatArray(totalSize);
env->SetFloatArrayRegion(result, 0, totalSize, melData);
return result;
}
} // extern "C"

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bubble_kazeia" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="4dp" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bubble_patient" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="4dp"
android:bottomRightRadius="16dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E8FEF7FF" />
<corners android:topLeftRadius="20dp" android:topRightRadius="20dp" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Landscape: rounded corners on the left side (chat panel is on the right) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E8FEF7FF" />
<corners android:topLeftRadius="20dp" android:bottomLeftRadius="20dp" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#60000000" />
<corners android:radius="2dp" />
<size android:width="48dp" android:height="4dp" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F5F5F5" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="#E0E0E0" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#E0E0E0" />
<size android:width="52dp" android:height="52dp" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#F44336" />
<size android:width="52dp" android:height="52dp" />
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FFFFFF">
<item>
<shape android:shape="oval">
<solid android:color="#99000000" />
</shape>
</item>
</ripple>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#D32F2F" />
<corners android:radius="8dp" />
</shape>

View File

@ -0,0 +1,270 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:background="@color/kazeia_background">
<!-- LEFT: Chat panel -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<!-- Header -->
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
android:background="@color/kazeia_primary"
app:title="Kazeia"
app:titleTextColor="@android:color/white"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:orientation="horizontal"
android:layout_marginEnd="8dp">
<ImageButton
android:id="@+id/btnDebugToggle"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@android:drawable/ic_menu_manage"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Debug"
android:tint="@android:color/white" />
<ImageButton
android:id="@+id/btnQuit"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Quitter"
android:tint="#FF8A80" />
</LinearLayout>
</com.google.android.material.appbar.MaterialToolbar>
<!-- Status indicator -->
<TextView
android:id="@+id/tvStatus"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/kazeia_accent"
android:gravity="center"
android:paddingVertical="6dp"
android:text="@string/status_loading"
android:textColor="@color/text_secondary"
android:textSize="13sp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Voice selector -->
<LinearLayout
android:id="@+id/voiceBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:background="#F5F0FA"
app:layout_constraintTop_toBottomOf="@id/tvStatus"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Voix:"
android:textSize="13sp"
android:textColor="@color/text_secondary" />
<Spinner
android:id="@+id/spinnerVoice"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:layout_marginStart="8dp" />
</LinearLayout>
<!-- Chat messages -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:padding="8dp"
app:layout_constraintTop_toBottomOf="@id/voiceBar"
app:layout_constraintBottom_toTopOf="@id/inputBar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Input bar -->
<LinearLayout
android:id="@+id/inputBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="@color/kazeia_surface"
android:elevation="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/hint_message"
android:maxLines="4"
android:inputType="textMultiLine|textCapSentences"
android:background="@drawable/bg_input"
android:padding="12dp"
android:textSize="16sp" />
<ImageButton
android:id="@+id/btnMic"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_btn_speak_now"
android:background="@drawable/bg_mic_button"
android:padding="8dp"
android:contentDescription="@string/btn_mic" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_menu_send"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_send" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Divider (debug panel) -->
<View
android:id="@+id/debugDivider"
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#E0E0E0"
android:visibility="gone" />
<!-- RIGHT: Monitoring + Logs panel (debug, hidden by default) -->
<LinearLayout
android:id="@+id/debugPanel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone">
<!-- Resource graphs (2x2 grid) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="3"
android:orientation="vertical"
android:background="#1E1E1E">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphCpu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphGpu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphNpu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphRam"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Logs header + content -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#2D2D2D"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="4dp"
android:text="Logs"
android:textColor="@android:color/white"
android:textSize="13sp"
android:textStyle="bold" />
<ScrollView
android:id="@+id/svLogs"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="4"
android:background="#1E1E1E"
android:fillViewport="true">
<TextView
android:id="@+id/tvLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:fontFamily="monospace"
android:textSize="11sp"
android:textColor="#CCCCCC"
android:lineSpacingMultiplier="1.2" />
</ScrollView>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:background="@color/kazeia_primary"
android:padding="48dp">
<!-- App name -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Kazeia"
android:textSize="48sp"
android:textStyle="bold"
android:textColor="@android:color/white"
android:fontFamily="sans-serif-light" />
<!-- Subtitle -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Compagnon de support émotionnel"
android:textSize="16sp"
android:textColor="#CCBBDD"
android:layout_marginTop="8dp" />
<!-- Spacer -->
<android.widget.Space
android:layout_width="match_parent"
android:layout_height="80dp" />
<!-- Loading step text -->
<TextView
android:id="@+id/tvLoadingStep"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Démarrage…"
android:textSize="14sp"
android:textColor="#DDC8EE"
android:layout_marginBottom="16dp" />
<!-- Progress bar -->
<ProgressBar
android:id="@+id/progressBar"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:max="100"
android:progress="0"
android:progressTint="@android:color/white"
android:progressBackgroundTint="#44FFFFFF" />
<!-- Percentage -->
<TextView
android:id="@+id/tvPercent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0%"
android:textSize="13sp"
android:textColor="#AABBCC"
android:layout_marginTop="8dp" />
</LinearLayout>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="4dp"
android:paddingHorizontal="8dp">
<LinearLayout
android:id="@+id/bubbleContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:maxWidth="320dp"
android:padding="12dp"
android:layout_gravity="start">
<TextView
android:id="@+id/tvSender"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/text_secondary"
android:paddingBottom="2dp" />
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textColor="@color/text_primary"
android:lineSpacingMultiplier="1.2" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/text_secondary"
android:paddingTop="4dp"
android:layout_gravity="end" />
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,271 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Main overlay on top of Unity GameActivity.
Simple structure: toolbar + content area (chat or debug).
All views are basic Android widgets — no Material components.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#00000000">
<!-- ===== TOOLBAR ===== -->
<LinearLayout
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="44dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="8dp"
android:background="#CC6750A4">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Kazeia"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold"
android:paddingStart="8dp" />
<!-- Status -->
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="En attente"
android:textColor="#DDBBFF"
android:textSize="11sp"
android:paddingHorizontal="12dp" />
<!-- Voice selector -->
<Spinner
android:id="@+id/spinnerVoice"
android:layout_width="120dp"
android:layout_height="32dp" />
<!-- Toggle: show chat -->
<ImageButton
android:id="@+id/btnToggleChat"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_menu_sort_by_size"
android:background="@drawable/bg_overlay_button"
android:padding="6dp"
android:contentDescription="Chat" />
<!-- Toggle: show debug -->
<ImageButton
android:id="@+id/btnToggleDebug"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_menu_manage"
android:background="@drawable/bg_overlay_button"
android:padding="6dp"
android:contentDescription="Debug" />
<!-- Quit app -->
<ImageButton
android:id="@+id/btnQuit"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:background="@drawable/bg_quit_button"
android:padding="6dp"
android:contentDescription="Quitter" />
</LinearLayout>
<!-- ===== CONTENT AREA ===== -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<!-- LEFT: Avatar + Chat -->
<LinearLayout
android:id="@+id/chatPanel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:background="#00000000">
<!-- Top: transparent (avatar/Unity visible) -->
<View
android:id="@+id/avatarSpacer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="40" />
<!-- Bottom: Chat -->
<LinearLayout
android:id="@+id/chatContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="60"
android:orientation="vertical"
android:background="@drawable/bg_chat_panel">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:padding="8dp" />
<!-- Input bar -->
<LinearLayout
android:id="@+id/inputBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="#18000000">
<EditText
android:id="@+id/etMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Ecrivez votre message..."
android:maxLines="3"
android:inputType="textMultiLine|textCapSentences"
android:background="@drawable/bg_input"
android:padding="10dp"
android:textSize="15sp" />
<ImageButton
android:id="@+id/btnMic"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_btn_speak_now"
android:background="@drawable/bg_mic_button"
android:padding="8dp"
android:contentDescription="Micro" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_menu_send"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Envoyer" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<!-- RIGHT: Debug panel (side panel, toggled visible/gone) -->
<LinearLayout
android:id="@+id/debugPanel"
android:layout_width="400dp"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#F0181818"
android:visibility="gone">
<!-- Resource graphs (2x2) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="200dp"
android:orientation="vertical"
android:background="#1E1E1E">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphCpu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphGpu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphNpu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
<com.kazeia.ui.MiniGraphView
android:id="@+id/graphRam"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Logs header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#2D2D2D"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:text="Logs"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
<!-- Logs content -->
<ScrollView
android:id="@+id/svLogs"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#1E1E1E"
android:fillViewport="true">
<TextView
android:id="@+id/tvLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:fontFamily="monospace"
android:textSize="10sp"
android:textColor="#CCCCCC"
android:lineSpacingMultiplier="1.2" />
</ScrollView>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/kazeia_primary" />
<foreground android:drawable="@color/kazeia_accent" />
</adaptive-icon>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="kazeia_primary">#6750A4</color>
<color name="kazeia_primary_dark">#4A3880</color>
<color name="kazeia_accent">#E8DEF8</color>
<color name="kazeia_background">#FEF7FF</color>
<color name="kazeia_surface">#FFFBFE</color>
<color name="bubble_patient">#E8DEF8</color>
<color name="bubble_kazeia">#F3EDF7</color>
<color name="text_primary">#1C1B1F</color>
<color name="text_secondary">#49454F</color>
<color name="status_bar_color">#6750A4</color>
</resources>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kazeia</string>
<string name="hint_message">Écrivez votre message…</string>
<string name="btn_send">Envoyer</string>
<string name="btn_mic">Microphone</string>
<string name="status_idle">En attente</string>
<string name="status_listening">Écoute en cours…</string>
<string name="status_transcribing">Transcription…</string>
<string name="status_thinking">Kazeia réfléchit…</string>
<string name="status_speaking">Kazeia parle…</string>
<string name="status_loading">Chargement des modèles…</string>
<string name="status_error">Erreur</string>
<string name="notification_channel">Kazeia Service</string>
<string name="notification_title">Kazeia est actif</string>
<string name="notification_text">Compagnon d\'écoute émotionnelle</string>
</resources>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Kazeia" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/kazeia_primary</item>
<item name="colorPrimaryDark">@color/kazeia_primary_dark</item>
<item name="colorAccent">@color/kazeia_accent</item>
<item name="android:statusBarColor">@color/status_bar_color</item>
<item name="android:windowBackground">@color/kazeia_background</item>
</style>
<style name="Theme.Kazeia.Splash" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/kazeia_primary</item>
<item name="android:statusBarColor">@color/kazeia_primary</item>
<item name="android:navigationBarColor">@color/kazeia_primary</item>
<item name="android:windowBackground">@color/kazeia_primary</item>
</style>
</resources>

View File

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

View File

@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
# Unity as a Library (UaaL)
unityStreamingAssets=

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
kazeia-android/gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
kazeia-android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,23 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "Kazeia"
include(":app")
// Unity as a Library (UaaL) — DISABLED
// include(":unityLibrary")
// project(":unityLibrary").projectDir = file("unityLibrary/unityLibrary")

830
kazeia-architecture.md Normal file
View File

@ -0,0 +1,830 @@
# Architecture Modulaire Kazeia - MVP Android
## Principe
Chaque composant (LLM, STT, TTS, VAD) est une interface Kotlin.
L'implémentation concrète est injectée au démarrage.
Changer de LLM = écrire une nouvelle classe qui implémente l'interface, rien d'autre ne bouge.
---
## Arborescence du projet
```
kazeia-android/
├── app/
│ ├── build.gradle.kts
│ ├── src/main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com/kazeia/
│ │ │ │
│ │ │ ├── KazeiaApplication.kt # Application, init des modules
│ │ │ │
│ │ │ ├── ui/ # Interface utilisateur
│ │ │ │ ├── ChatActivity.kt # Activity principale (chat)
│ │ │ │ ├── ChatAdapter.kt # RecyclerView adapter messages
│ │ │ │ └── ChatMessage.kt # Data class message
│ │ │ │
│ │ │ ├── service/ # Foreground Service
│ │ │ │ ├── KazeiaService.kt # Service principal, orchestre tout
│ │ │ │ └── ServiceBinder.kt # Binder pour Activity ↔ Service
│ │ │ │
│ │ │ ├── core/ # Interfaces (contrats)
│ │ │ │ ├── LlmEngine.kt # Interface LLM
│ │ │ │ ├── SttEngine.kt # Interface STT
│ │ │ │ ├── TtsEngine.kt # Interface TTS
│ │ │ │ ├── VadEngine.kt # Interface VAD
│ │ │ │ └── ConversationState.kt # États de la conversation
│ │ │ │
│ │ │ ├── llm/ # Implémentations LLM
│ │ │ │ ├── GenieLlmEngine.kt # Qwen3-4B via Genie SDK (NPU)
│ │ │ │ ├── ExecuTorchLlmEngine.kt # Qwen3 via ExecuTorch (NPU)
│ │ │ │ └── LlamaCppLlmEngine.kt # Fallback CPU via llama.cpp
│ │ │ │
│ │ │ ├── stt/ # Implémentations STT
│ │ │ │ ├── WhisperSttEngine.kt # Whisper Qualcomm (NPU)
│ │ │ │ └── AndroidSttEngine.kt # SpeechRecognizer natif (fallback)
│ │ │ │
│ │ │ ├── tts/ # Implémentations TTS
│ │ │ │ ├── ChatterboxTtsEngine.kt # Chatterbox
│ │ │ │ └── AndroidTtsEngine.kt # TTS natif Android (fallback)
│ │ │ │
│ │ │ ├── vad/ # Implémentations VAD
│ │ │ │ └── SileroVadEngine.kt # Silero VAD (ONNX)
│ │ │ │
│ │ │ ├── audio/ # Gestion audio
│ │ │ │ ├── AudioCaptureManager.kt # AudioRecord continu
│ │ │ │ ├── AudioPlaybackManager.kt # AudioTrack pour TTS
│ │ │ │ └── EchoCancellationManager.kt # Gestion AEC
│ │ │ │
│ │ │ ├── conversation/ # Logique métier
│ │ │ │ ├── ConversationManager.kt # Machine à états
│ │ │ │ ├── PromptBuilder.kt # Construction du prompt
│ │ │ │ └── StoppingCriteria.kt # Critères d'arrêt LLM
│ │ │ │
│ │ │ └── data/ # Persistance
│ │ │ ├── KazeiaDatabase.kt # SQLite helper
│ │ │ ├── ConversationRepository.kt # CRUD conversations
│ │ │ └── PatientRepository.kt # CRUD patients
│ │ │
│ │ ├── res/
│ │ │ ├── layout/
│ │ │ │ └── activity_chat.xml # Layout chat simple
│ │ │ └── values/
│ │ │ └── strings.xml
│ │ │
│ │ └── assets/ # Modèles embarqués
│ │ └── silero_vad.onnx # Modèle VAD (1.8 Mo)
│ │
│ └── libs/ # Bibliothèques natives .so
│ └── arm64-v8a/
│ ├── libgenie.so # Genie SDK
│ ├── libQnn*.so # QNN runtime libs
│ └── libchatterbox.so # Chatterbox TTS
├── gradle/
├── build.gradle.kts # Project-level
├── settings.gradle.kts
└── local.properties
```
---
## Interfaces (core/)
### LlmEngine.kt — Le contrat LLM
```kotlin
package com.kazeia.core
/**
* Interface pour tout moteur LLM.
* Implémentations : GenieLlmEngine, ExecuTorchLlmEngine, LlamaCppLlmEngine
*/
interface LlmEngine {
/** Charge le modèle en mémoire. Appelé une fois au démarrage du Service. */
suspend fun load(modelPath: String, config: LlmConfig)
/** Vérifie si le modèle est chargé et prêt. */
fun isLoaded(): Boolean
/**
* Génère une réponse en streaming.
* @param prompt Le prompt complet (système + contexte + message)
* @param params Paramètres de sampling
* @param onToken Callback appelé pour chaque token généré
* @return La réponse complète
*/
suspend fun generate(
prompt: String,
params: SamplingParams = SamplingParams(),
onToken: ((String) -> Boolean)? = null // retourne false pour stopper
): GenerationResult
/** Libère les ressources. */
fun release()
}
data class LlmConfig(
val backend: String = "npu", // "npu", "cpu", "gpu"
val maxContextLength: Int = 4096,
val kvCacheQuantization: String = "int8"
)
data class SamplingParams(
val maxNewTokens: Int = 120,
val temperature: Float = 0.7f,
val topP: Float = 0.85f,
val topK: Int = 40,
val repetitionPenalty: Float = 1.2f
)
data class GenerationResult(
val text: String,
val tokenCount: Int,
val timeMs: Long,
val tokensPerSecond: Float
)
```
### SttEngine.kt — Le contrat STT
```kotlin
package com.kazeia.core
/**
* Interface pour tout moteur Speech-to-Text.
* Implémentations : WhisperSttEngine, AndroidSttEngine
*/
interface SttEngine {
suspend fun load(modelPath: String? = null)
fun isLoaded(): Boolean
/**
* Transcrit un segment audio.
* @param audioData PCM 16-bit mono 16kHz
* @param language Code langue ("fr")
* @return Texte transcrit
*/
suspend fun transcribe(
audioData: ShortArray,
language: String = "fr"
): TranscriptionResult
fun release()
}
data class TranscriptionResult(
val text: String,
val confidence: Float,
val language: String,
val durationMs: Long
)
```
### TtsEngine.kt — Le contrat TTS
```kotlin
package com.kazeia.core
/**
* Interface pour tout moteur Text-to-Speech.
* Implémentations : ChatterboxTtsEngine, AndroidTtsEngine
*/
interface TtsEngine {
suspend fun load(modelPath: String? = null, voiceId: String? = null)
fun isLoaded(): Boolean
/**
* Synthétise du texte en audio.
* @param text Texte à synthétiser
* @param language Code langue ("fr")
* @return Audio PCM
*/
suspend fun synthesize(
text: String,
language: String = "fr"
): TtsResult
/**
* Synthétise et joue directement.
* @param onStart Callback quand la lecture commence
* @param onComplete Callback quand la lecture est terminée
*/
suspend fun synthesizeAndPlay(
text: String,
language: String = "fr",
onStart: (() -> Unit)? = null,
onComplete: (() -> Unit)? = null
)
/** Arrête la lecture en cours. */
fun stop()
fun release()
}
data class TtsResult(
val audioData: ShortArray,
val sampleRate: Int = 24000,
val durationMs: Long
)
```
### VadEngine.kt — Le contrat VAD
```kotlin
package com.kazeia.core
/**
* Interface pour tout moteur Voice Activity Detection.
* Implémentation : SileroVadEngine
*/
interface VadEngine {
fun load(context: android.content.Context)
fun isLoaded(): Boolean
/**
* Analyse un frame audio.
* @param frame PCM 16-bit mono 16kHz, 512 samples (32ms)
* @return true si de la parole est détectée
*/
fun isSpeech(frame: ShortArray): Boolean
/** Réinitialise l'état interne (entre deux patients par ex.) */
fun resetState()
fun release()
}
```
### ConversationState.kt — Les événements du pipeline
```kotlin
package com.kazeia.core
/**
* États observables du pipeline.
* L'UI observe ces états pour mettre à jour l'affichage.
*/
sealed class PipelineState {
object Idle : PipelineState() // En attente
object Listening : PipelineState() // VAD actif, attend la parole
object SpeechDetected : PipelineState() // Parole en cours
object Transcribing : PipelineState() // Whisper transcrit
data class Transcribed(val text: String) : PipelineState() // Texte prêt
object Thinking : PipelineState() // LLM génère
data class TokenGenerated(val token: String, val fullText: String) : PipelineState()
data class ResponseReady(val text: String) : PipelineState()
object Speaking : PipelineState() // TTS joue
data class Error(val message: String) : PipelineState()
}
/**
* Message dans la conversation.
*/
data class ChatMessage(
val id: Long = System.currentTimeMillis(),
val role: Role,
val text: String,
val timestamp: Long = System.currentTimeMillis()
) {
enum class Role { PATIENT, KAZEIA, SYSTEM }
}
```
---
## Service principal — L'orchestrateur
### KazeiaService.kt
```kotlin
package com.kazeia.service
/**
* Foreground Service qui orchestre tout le pipeline.
* Les modèles restent en mémoire tant que le Service tourne.
*
* Pipeline :
* Micro → VAD → [parole détectée] → STT → texte
* → PromptBuilder → LLM (streaming) → texte réponse
* → TTS → audio → haut-parleur
*/
class KazeiaService : Service() {
// Composants injectés — facilement interchangeables
private lateinit var llm: LlmEngine
private lateinit var stt: SttEngine
private lateinit var tts: TtsEngine
private lateinit var vad: VadEngine
// Audio
private lateinit var audioCapture: AudioCaptureManager
private lateinit var echoManager: EchoCancellationManager
// Logique
private lateinit var conversationManager: ConversationManager
private lateinit var promptBuilder: PromptBuilder
private lateinit var stoppingCriteria: StoppingCriteria
// État observable
private val _pipelineState = MutableStateFlow<PipelineState>(PipelineState.Idle)
val pipelineState: StateFlow<PipelineState> = _pipelineState
// Messages
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
startForeground(NOTIFICATION_ID, createNotification())
initializeComponents()
}
private fun initializeComponents() {
serviceScope.launch {
// ============================================
// POINT D'INJECTION : changer d'implémentation
// ici pour switcher de backend
// ============================================
llm = GenieLlmEngine() // ← swap ici
stt = WhisperSttEngine() // ← swap ici
tts = ChatterboxTtsEngine() // ← swap ici
vad = SileroVadEngine()
// Charger les modèles
llm.load("$filesDir/models/qwen3-4b", LlmConfig(backend = "npu"))
stt.load("$filesDir/models/whisper")
tts.load("$filesDir/models/chatterbox", voiceId = "kazeia_fr")
vad.load(this@KazeiaService)
// Initialiser l'audio
echoManager = EchoCancellationManager()
audioCapture = AudioCaptureManager(
onSpeechSegment = { audio -> handleSpeechSegment(audio) }
)
// Logique
promptBuilder = PromptBuilder()
stoppingCriteria = StoppingCriteria()
conversationManager = ConversationManager()
// Démarrer l'écoute VAD
startListening()
}
}
private fun startListening() {
_pipelineState.value = PipelineState.Listening
audioCapture.start(vad) { speechAudio ->
// Callback : le VAD a détecté une phrase complète
serviceScope.launch {
processSpeechInput(speechAudio)
}
}
}
/**
* Traite un input vocal (depuis le VAD)
*/
private suspend fun processSpeechInput(audioData: ShortArray) {
_pipelineState.value = PipelineState.Transcribing
// STT
val transcription = stt.transcribe(audioData, language = "fr")
if (transcription.text.isBlank()) {
_pipelineState.value = PipelineState.Listening
return
}
_pipelineState.value = PipelineState.Transcribed(transcription.text)
// Ajouter le message patient
addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = transcription.text))
// Traiter via le LLM
processLlmResponse(transcription.text)
}
/**
* Traite un input texte (depuis le clavier)
*/
fun processTextInput(text: String) {
serviceScope.launch {
addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = text))
processLlmResponse(text)
}
}
/**
* Coeur du pipeline : LLM → TTS
*/
private suspend fun processLlmResponse(patientMessage: String) {
_pipelineState.value = PipelineState.Thinking
// Construire le prompt
val prompt = promptBuilder.build(
message = patientMessage,
history = _messages.value
)
// Générer la réponse en streaming
val responseBuilder = StringBuilder()
val sentenceBuffer = StringBuilder()
val result = llm.generate(
prompt = prompt,
params = SamplingParams(
maxNewTokens = 120,
temperature = conversationManager.currentTemperature()
),
onToken = { token ->
responseBuilder.append(token)
sentenceBuffer.append(token)
_pipelineState.value = PipelineState.TokenGenerated(
token = token,
fullText = responseBuilder.toString()
)
// Quand on a une phrase complète, l'envoyer au TTS
val sentence = sentenceBuffer.toString()
if (sentence.contains('.') || sentence.contains('?') || sentence.contains('!')) {
val completeSentence = sentence.trim()
sentenceBuffer.clear()
serviceScope.launch {
speakSentence(completeSentence)
}
}
// Critères d'arrêt
stoppingCriteria.shouldStop(responseBuilder.toString())
}
)
// Jouer le reste du buffer s'il reste du texte
if (sentenceBuffer.isNotEmpty()) {
speakSentence(sentenceBuffer.toString().trim())
}
// Ajouter la réponse complète
addMessage(ChatMessage(role = ChatMessage.Role.KAZEIA, text = result.text))
_pipelineState.value = PipelineState.Listening
}
/**
* Synthétise et joue une phrase.
*/
private suspend fun speakSentence(sentence: String) {
if (sentence.isBlank()) return
_pipelineState.value = PipelineState.Speaking
echoManager.onTtsStart()
tts.synthesizeAndPlay(
text = sentence,
language = "fr",
onStart = { echoManager.onTtsStart() },
onComplete = { echoManager.onTtsStop() }
)
}
private fun addMessage(message: ChatMessage) {
_messages.value = _messages.value + message
}
// ... Binder, notification, lifecycle
}
```
---
## Audio Capture avec VAD
### AudioCaptureManager.kt
```kotlin
package com.kazeia.audio
/**
* Gère le micro en continu et utilise le VAD pour détecter la parole.
* Quand une phrase complète est détectée (parole suivie de silence),
* le callback onSpeechSegment est appelé avec l'audio brut.
*/
class AudioCaptureManager(
private val sampleRate: Int = 16000
) {
private var audioRecord: AudioRecord? = null
private var isRunning = false
private var listenerThread: Thread? = null
fun start(
vad: VadEngine,
silenceDurationMs: Int = 800, // Patient thérapeutique = pauses longues
speechMinDurationMs: Int = 150, // Éviter les faux positifs
onSpeechSegment: (ShortArray) -> Unit
) {
val frameSize = 512 // 32ms à 16kHz
val frameDurationMs = (frameSize.toFloat() / sampleRate * 1000).toInt()
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION, // AEC activé
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
sampleRate * 2 // 1s buffer
).also { it.startRecording() }
isRunning = true
listenerThread = thread(name = "AudioCapture-VAD") {
val frame = ShortArray(frameSize)
val speechBuffer = mutableListOf<ShortArray>()
var speechFrameCount = 0
var silenceFrameCount = 0
var isSpeechActive = false
val silenceFramesNeeded = silenceDurationMs / frameDurationMs
val speechFramesNeeded = speechMinDurationMs / frameDurationMs
while (isRunning) {
val read = audioRecord?.read(frame, 0, frameSize) ?: 0
if (read != frameSize) continue
val isSpeech = vad.isSpeech(frame)
if (isSpeech) {
silenceFrameCount = 0
speechFrameCount++
speechBuffer.add(frame.copyOf())
if (speechFrameCount >= speechFramesNeeded && !isSpeechActive) {
isSpeechActive = true
}
} else {
if (isSpeechActive) {
silenceFrameCount++
speechBuffer.add(frame.copyOf()) // garder le silence de transition
if (silenceFrameCount >= silenceFramesNeeded) {
// Fin de parole détectée
val fullAudio = speechBuffer.flatMap { it.toList() }.toShortArray()
onSpeechSegment(fullAudio)
speechBuffer.clear()
speechFrameCount = 0
silenceFrameCount = 0
isSpeechActive = false
}
} else {
// Pas de parole en cours, reset
speechBuffer.clear()
speechFrameCount = 0
}
}
}
}
}
fun stop() {
isRunning = false
listenerThread?.join(1000)
audioRecord?.stop()
audioRecord?.release()
}
}
```
---
## Logique conversationnelle
### PromptBuilder.kt
```kotlin
package com.kazeia.conversation
/**
* Construit le prompt optimisé pour le LLM.
* Compressé à ~200 tokens système + contexte dynamique.
*/
class PromptBuilder {
private val systemPrompt = """
Tu es Kazeia, compagnon d'écoute émotionnelle en français.
RÈGLES: Valide l'émotion. 2-3 phrases max. Pas de diagnostic. Risque suicidaire→3114. Pose UNE question ouverte.
""".trimIndent()
fun build(
message: String,
history: List<ChatMessage>,
maxHistoryTurns: Int = 4
): String = buildString {
append(systemPrompt)
append("\n")
// Derniers tours de conversation
val recentHistory = history.takeLast(maxHistoryTurns * 2)
for (msg in recentHistory) {
when (msg.role) {
ChatMessage.Role.PATIENT -> append("Patient: ${msg.text}\n")
ChatMessage.Role.KAZEIA -> append("Kazeia: ${msg.text}\n")
else -> {}
}
}
// Message actuel
append("Patient: $message\n")
append("Kazeia:")
}
}
```
### StoppingCriteria.kt
```kotlin
package com.kazeia.conversation
/**
* Détermine quand le LLM doit arrêter de générer.
* Optimisé pour des réponses empathiques courtes.
*/
class StoppingCriteria(
private val maxSentences: Int = 3,
private val stopAfterQuestion: Boolean = true,
private val maxTokens: Int = 120
) {
private var tokenCount = 0
fun shouldStop(generatedText: String): Boolean {
tokenCount++
// Limite dure de tokens
if (tokenCount >= maxTokens) return true
// Compter les phrases
val sentenceEnders = generatedText.count { it == '.' || it == '!' || it == '?' }
if (sentenceEnders >= maxSentences) return true
// Arrêter après une question (comportement empathique)
if (stopAfterQuestion && generatedText.contains('?') && tokenCount > 15) {
return true
}
return false
}
fun reset() { tokenCount = 0 }
}
```
---
## build.gradle.kts (app)
```kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.kazeia"
compileSdk = 36
defaultConfig {
applicationId = "com.kazeia"
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "0.1.0-mvp"
ndk {
abiFilters += "arm64-v8a" // OnePlus Pad 3 uniquement
}
}
buildFeatures {
viewBinding = true
}
}
dependencies {
// Android
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("com.google.android.material:material:1.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Lifecycle (StateFlow observation)
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
// VAD - Silero (ONNX Runtime intégré)
implementation("com.github.gkonovalov.android-vad:silero:1.0.2")
// ONNX Runtime (pour d'autres modèles si besoin)
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.20.0")
// SQLite
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
// Les bibliothèques natives (Genie, Whisper, Chatterbox)
// sont fournies en .so dans app/libs/arm64-v8a/
}
```
---
## AndroidManifest.xml
```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".KazeiaApplication"
android:largeHeap="true"
android:label="Kazeia"
android:theme="@style/Theme.Material3.DayNight">
<activity
android:name=".ui.ChatActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.KazeiaService"
android:foregroundServiceType="microphone"
android:exported="false" />
</application>
</manifest>
```
---
## Notes pour la session Claude Code
### Modèles à déployer sur la tablette
Les modèles ne sont PAS dans l'APK. Ils sont poussés via ADB :
```bash
adb push qwen3-4b-genie/ /data/local/tmp/kazeia/models/qwen3-4b/
adb push whisper-qualcomm/ /data/local/tmp/kazeia/models/whisper/
adb push chatterbox/ /data/local/tmp/kazeia/models/chatterbox/
```
L'application au démarrage vérifie la présence des modèles et affiche une erreur si manquants.
### Ordre d'implémentation recommandé
1. **Interface chat basique** (Activity + RecyclerView) — sans IA, juste l'UI
2. **LLM texte seul** (taper un message → Genie répond → affichage)
3. **VAD + micro** (AudioRecord + Silero → détection parole)
4. **STT** (Whisper → transcription affichée)
5. **TTS** (réponse LLM → Chatterbox → audio)
6. **Pipeline complet** (VAD → STT → LLM → TTS, sans bouton)
### Swap de LLM
Pour changer de LLM, il suffit de modifier UNE ligne dans KazeiaService.kt :
```kotlin
// Option A : Genie SDK (NPU, Qwen3-4B pré-compilé)
llm = GenieLlmEngine()
// Option B : ExecuTorch (NPU, modèle custom .pte)
llm = ExecuTorchLlmEngine()
// Option C : llama.cpp (CPU, n'importe quel GGUF)
llm = LlamaCppLlmEngine()
```
Toutes les implémentations respectent la même interface LlmEngine.
Le reste de l'application ne change pas.

207
scripts/cp_et_runner.cpp Normal file
View File

@ -0,0 +1,207 @@
/**
* TTS Code Predictor Runner ExecuTorch .pte on NPU HTP.
* Based on executor_runner.cpp but with socket IPC for the app.
* Same protocol as the GGUF CP runner.
*/
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <chrono>
#include <memory>
#include <vector>
#include <gflags/gflags.h>
#include <executorch/extension/data_loader/file_data_loader.h>
#include <executorch/extension/runner_util/inputs.h>
#include <executorch/runtime/executor/method.h>
#include <executorch/runtime/executor/program.h>
#include <executorch/runtime/platform/runtime.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
DEFINE_string(model_path, "", "Path to .pte file");
DEFINE_string(sock_path, "/data/local/tmp/kazeia/cp_et.sock", "Socket path");
DEFINE_string(heads_path, "/data/local/tmp/kazeia/models/cp_heads.bin", "Heads file");
DEFINE_string(embs_path, "/data/local/tmp/kazeia/models/cp_codec_embs.bin", "Codec embs file");
DEFINE_string(cos_path, "/data/local/tmp/kazeia/models/qwen3-tts-npu/cp_kv_v2/cp_rotary_cos.npy", "Cos file");
DEFINE_string(sin_path, "/data/local/tmp/kazeia/models/qwen3-tts-npu/cp_kv_v2/cp_rotary_sin.npy", "Sin file");
using executorch::runtime::Error;
using executorch::runtime::EValue;
using executorch::runtime::HierarchicalAllocator;
using executorch::runtime::MemoryAllocator;
using executorch::runtime::MemoryManager;
using executorch::runtime::Method;
using executorch::runtime::Program;
using executorch::runtime::Result;
using executorch::runtime::Span;
static const int N_EMBD=1024, N_VOCAB=2048, N_CB=15, N_KV=8, HD=128, KV_LEN=16, N_L=5;
static bool read_exact(int fd,void*buf,size_t n){
size_t d=0;while(d<n){ssize_t r=read(fd,(char*)buf+d,n-d);if(r<=0)return false;d+=r;}return true;
}
static bool write_exact(int fd,const void*buf,size_t n){
size_t d=0;while(d<n){ssize_t r=write(fd,(const char*)buf+d,n-d);if(r<=0)return false;d+=r;}return true;
}
static float* load_npy(const char*p,int n){
FILE*f=fopen(p,"rb");if(!f)return nullptr;
unsigned char h[10];fread(h,1,10,f);
int hl=h[8]|(h[9]<<8);fseek(f,10+hl,SEEK_SET);
float*d=(float*)malloc(n*4);fread(d,4,n,f);fclose(f);return d;
}
static uint8_t method_allocator_pool[4 * 1024U * 1024U]; // 4MB
static uint8_t temp_allocator_pool[1024U * 1024U]; // 1MB
int main(int argc, char** argv) {
executorch::runtime::runtime_init();
gflags::ParseCommandLineFlags(&argc, &argv, true);
if (FLAGS_model_path.empty()) {
fprintf(stderr, "Usage: cp_et_runner --model_path=model.pte\n");
return 1;
}
// Load program
auto loader = executorch::extension::FileDataLoader::from(FLAGS_model_path.c_str());
ET_CHECK_MSG(loader.ok(), "Failed to load %s", FLAGS_model_path.c_str());
auto program = Program::load(&loader.get());
ET_CHECK_MSG(program.ok(), "Failed to parse program");
// Setup memory — allocate planned buffers from program metadata
MemoryAllocator method_allocator(sizeof(method_allocator_pool), method_allocator_pool);
auto temp_allocator = MemoryAllocator(sizeof(temp_allocator_pool), temp_allocator_pool);
auto method_meta = program->method_meta("forward");
ET_CHECK_MSG(method_meta.ok(), "Failed to get method meta");
std::vector<std::unique_ptr<uint8_t[]>> planned_bufs;
std::vector<Span<uint8_t>> planned_spans;
size_t n_planned = method_meta->num_memory_planned_buffers();
for (size_t id = 0; id < n_planned; id++) {
size_t sz = (size_t)method_meta->memory_planned_buffer_size(id).get();
planned_bufs.push_back(std::make_unique<uint8_t[]>(sz));
planned_spans.push_back({planned_bufs.back().get(), sz});
}
HierarchicalAllocator planned_memory({planned_spans.data(), planned_spans.size()});
MemoryManager memory_manager(&method_allocator, &planned_memory, &temp_allocator);
// Load method
auto method = program->load_method("forward", &memory_manager);
ET_CHECK_MSG(method.ok(), "Failed to load method: 0x%x", (int)method.error());
auto meta = method->method_meta();
fprintf(stderr, "CP_ET: %zu inputs, %zu outputs\n", meta.num_inputs(), meta.num_outputs());
// Load heads, embeddings, rotary
float* heads = (float*)malloc(N_CB * N_VOCAB * N_EMBD * 4);
float* embs_data = (float*)malloc(N_CB * N_VOCAB * N_EMBD * 4);
FILE* fh = fopen(FLAGS_heads_path.c_str(), "rb");
if (fh) { fread(heads, 4, N_CB*N_VOCAB*N_EMBD, fh); fclose(fh); }
FILE* fe = fopen(FLAGS_embs_path.c_str(), "rb");
if (fe) { fread(embs_data, 4, N_CB*N_VOCAB*N_EMBD, fe); fclose(fe); }
float* rcos = load_npy(FLAGS_cos_path.c_str(), 17*HD);
float* rsin = load_npy(FLAGS_sin_path.c_str(), 17*HD);
// Socket setup
unlink(FLAGS_sock_path.c_str());
int srv = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {}; addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, FLAGS_sock_path.c_str(), sizeof(addr.sun_path)-1);
bind(srv, (struct sockaddr*)&addr, sizeof(addr));
chmod(FLAGS_sock_path.c_str(), 0666);
listen(srv, 1);
fprintf(stderr, "CP_ET READY on %s\n", FLAGS_sock_path.c_str());
while (true) {
int cli = accept(srv, nullptr, nullptr);
if (cli < 0) break;
float input[2 * N_EMBD];
while (read_exact(cli, input, sizeof(input))) {
auto t0 = std::chrono::high_resolution_clock::now();
float* hidden_in = input;
float* cb0_emb = input + N_EMBD;
int kv_elem = N_KV * KV_LEN * HD;
std::vector<float> kv(N_L * 2 * kv_elem, 0.0f);
int codes[N_CB] = {};
float* emb = hidden_in;
for (int step = 0; step < 17; step++) {
if (step == 1) emb = cb0_emb;
else if (step >= 2) emb = embs_data + ((step-2)*N_VOCAB + codes[step-2]) * N_EMBD;
// Prepare input tensors (allocates buffers matching the method's expectations)
auto prep = executorch::extension::prepare_input_tensors(method.get());
if (!prep.ok()) { fprintf(stderr, "prep fail %d\n", step); break; }
// Copy our data into the prepared tensors
// Input 0: emb [1,1,1024]
memcpy(method->mutable_input(0).toTensor().mutable_data_ptr<float>(), emb, N_EMBD*4);
// Input 1: mask [1,1,1,16]
float* mp = method->mutable_input(1).toTensor().mutable_data_ptr<float>();
for (int p = 0; p < KV_LEN; p++) mp[p] = (p >= KV_LEN-1-step) ? 0.0f : -1e9f;
// Input 2: cos [1,1,128]
memcpy(method->mutable_input(2).toTensor().mutable_data_ptr<float>(), rcos+step*HD, HD*4);
// Input 3: sin [1,1,128]
memcpy(method->mutable_input(3).toTensor().mutable_data_ptr<float>(), rsin+step*HD, HD*4);
// Inputs 4-13: KV caches [1,8,16,128]
for (int l = 0; l < N_L; l++) {
memcpy(method->mutable_input(4+l*2).toTensor().mutable_data_ptr<float>(),
kv.data()+(l*2)*kv_elem, kv_elem*4);
memcpy(method->mutable_input(5+l*2).toTensor().mutable_data_ptr<float>(),
kv.data()+(l*2+1)*kv_elem, kv_elem*4);
}
auto status = method->execute();
if (status != Error::Ok) {
fprintf(stderr, "exec fail step %d: %d\n", step, (int)status);
break;
}
// Get hidden output
const float* h = method->get_output(0).toTensor().const_data_ptr<float>();
// Head argmax on CPU
if (step >= 1 && step-1 < N_CB) {
int cb = step - 1;
const float* W = heads + cb * N_VOCAB * N_EMBD;
int best = 0; float bv = -1e30f;
for (int j = 0; j < N_VOCAB; j++) {
float dot = 0;
for (int k = 0; k < N_EMBD; k++) dot += h[k] * W[j*N_EMBD+k];
if (dot > bv) { bv = dot; best = j; }
}
codes[cb] = best;
}
// Update KV caches from outputs
for (int l = 0; l < N_L; l++) {
const float* ko = method->get_output(1+l*2).toTensor().const_data_ptr<float>();
const float* vo = method->get_output(2+l*2).toTensor().const_data_ptr<float>();
memcpy(kv.data()+(l*2)*kv_elem, ko, kv_elem*4);
memcpy(kv.data()+(l*2+1)*kv_elem, vo, kv_elem*4);
}
}
auto t1 = std::chrono::high_resolution_clock::now();
float ms = std::chrono::duration<float, std::milli>(t1-t0).count();
write_exact(cli, codes, sizeof(codes));
write_exact(cli, &ms, sizeof(ms));
}
close(cli);
}
free(heads); free(embs_data); free(rcos); free(rsin);
close(srv); unlink(FLAGS_sock_path.c_str());
return 0;
}

67
scripts/export_cp_pte.py Normal file
View File

@ -0,0 +1,67 @@
import os, sys, warnings, torch, torch.nn as nn, torch.nn.functional as F
sys.path = [p for p in sys.path if 'Kazeia/executorch' not in p and p != '.']
os.environ['QNN_SDK_ROOT'] = os.environ.get('QNN_SDK_ROOT', '')
warnings.filterwarnings('ignore')
N_L=5;N_H=16;N_KV=8;HD=128;DIM=1024;N_REP=2;CP_KV=16
state=torch.load("/opt/Kazeia/models_qnn/qwen3-tts-native/code_predictor_weights.pt",map_location="cpu",weights_only=False)
def rotate_half(x):x1=x[...,:x.shape[-1]//2];x2=x[...,x.shape[-1]//2:];return torch.cat((-x2,x1),dim=-1)
def repeat_kv(x,n):B,H,T,D=x.shape;return x[:,:,None,:,:].expand(B,H,n,T,D).reshape(B,H*n,T,D)
class RMSNorm(nn.Module):
def __init__(s,d):super().__init__();s.weight=nn.Parameter(torch.ones(d))
def forward(s,x):return x*torch.rsqrt(x.pow(2).mean(-1,keepdim=True)+1e-6)*s.weight
# Just the transformer, NO heads
class CPTransformer(nn.Module):
def __init__(s,st):
super().__init__();s.na=nn.ModuleList();s.nf=nn.ModuleList();s.qp=nn.ModuleList();s.kp=nn.ModuleList()
s.vp=nn.ModuleList();s.op=nn.ModuleList();s.qn=nn.ModuleList();s.kn=nn.ModuleList()
s.ga=nn.ModuleList();s.dn2=nn.ModuleList();s.up=nn.ModuleList()
for i in range(N_L):
p=f"model.layers.{i}.";a=RMSNorm(DIM);a.weight.data=st[p+"input_layernorm.weight"];s.na.append(a)
f=RMSNorm(DIM);f.weight.data=st[p+"post_attention_layernorm.weight"];s.nf.append(f)
s.qp.append(nn.Linear(DIM,N_H*HD,bias=False));s.qp[-1].weight.data=st[p+"self_attn.q_proj.weight"]
s.kp.append(nn.Linear(DIM,N_KV*HD,bias=False));s.kp[-1].weight.data=st[p+"self_attn.k_proj.weight"]
s.vp.append(nn.Linear(DIM,N_KV*HD,bias=False));s.vp[-1].weight.data=st[p+"self_attn.v_proj.weight"]
s.op.append(nn.Linear(N_H*HD,DIM,bias=False));s.op[-1].weight.data=st[p+"self_attn.o_proj.weight"]
q=RMSNorm(HD);q.weight.data=st[p+"self_attn.q_norm.weight"];s.qn.append(q)
k=RMSNorm(HD);k.weight.data=st[p+"self_attn.k_norm.weight"];s.kn.append(k)
s.ga.append(nn.Linear(DIM,3072,bias=False));s.ga[-1].weight.data=st[p+"mlp.gate_proj.weight"]
s.dn2.append(nn.Linear(3072,DIM,bias=False));s.dn2[-1].weight.data=st[p+"mlp.down_proj.weight"]
s.up.append(nn.Linear(DIM,3072,bias=False));s.up[-1].weight.data=st[p+"mlp.up_proj.weight"]
s.fn=RMSNorm(DIM);s.fn.weight.data=st["model.norm.weight"]
def forward(s,emb,mask,cos,sin,k0,v0,k1,v1,k2,v2,k3,v3,k4,v4):
h=emb;c=cos.unsqueeze(1);sn=sin.unsqueeze(1);kv=[k0,v0,k1,v1,k2,v2,k3,v3,k4,v4];nk=[]
for i in range(N_L):
kc=kv[i*2];vc=kv[i*2+1];r=h;hn=s.na[i](h)
q=s.qp[i](hn).view(1,1,N_H,HD).transpose(1,2);k=s.kp[i](hn).view(1,1,N_KV,HD).transpose(1,2);v=s.vp[i](hn).view(1,1,N_KV,HD).transpose(1,2)
q=s.qn[i](q);k=s.kn[i](k);q=q*c+rotate_half(q)*sn;k=k*c+rotate_half(k)*sn
kf=torch.cat([kc[:,:,1:,:],k],dim=2);vf=torch.cat([vc[:,:,1:,:],v],dim=2)
ke=repeat_kv(kf,N_REP);ve=repeat_kv(vf,N_REP)
sc=torch.matmul(q,ke.transpose(-2,-1))*(1.0/(HD**0.5))+mask
ao=torch.matmul(F.softmax(sc,dim=-1),ve).transpose(1,2).contiguous().view(1,1,-1)
h=r+s.op[i](ao);r=h;fn=s.nf[i](h);h=r+s.dn2[i](F.silu(s.ga[i](fn))*s.up[i](fn));nk.extend([kf,vf])
return (s.fn(h),nk[0],nk[1],nk[2],nk[3],nk[4],nk[5],nk[6],nk[7],nk[8],nk[9])
print("Building (no heads)...")
w=CPTransformer(state).eval()
print(f"Params: {sum(p.numel() for p in w.parameters())/1e6:.1f}M")
e=torch.randn(1,1,DIM);m=torch.full((1,1,1,CP_KV),-1e9);m[0,0,0,-1]=0
inv=1.0/(1e6**(torch.arange(0,HD,2,dtype=torch.float32)/HD))
c0=torch.cos(0*inv).repeat(2).unsqueeze(0).unsqueeze(0);s0=torch.sin(0*inv).repeat(2).unsqueeze(0).unsqueeze(0)
kvs=[torch.zeros(1,N_KV,CP_KV,HD) for _ in range(10)]
with torch.no_grad():out=w(e,m,c0,s0,*kvs)
print(f"Test: h={out[0].shape}")
from executorch.backends.qualcomm.utils.utils import *
htp=generate_htp_compiler_spec(use_fp16=True)
bo=QnnExecuTorchBackendOptions(backend_type=QnnExecuTorchBackendType.kHtpBackend,htp_options=htp)
specs=generate_qnn_executorch_compiler_spec(soc_model=QcomChipset.SM8750,backend_options=bo)
print("Lowering CP transformer (no heads) to QNN...")
edge=to_edge_transform_and_lower_to_qnn(w,(e,m,c0,s0,*kvs),compiler_specs=specs)
print("LOWERED!")
pte=edge.to_executorch()
OUT="/opt/Kazeia/models_qnn/cp_transformer_fp16.pte"
with open(OUT,"wb") as f:pte.write_to_file(f)
print(f"SAVED: {OUT} ({os.path.getsize(OUT)/1024/1024:.0f} MB)")

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Export Qwen3-TTS talker transformer to ExecuTorch .pte on QNN HTP fp16.
28 layers, 1024 dim, GQA 16/8, M-RoPE, codec_head.
Fixed KV cache with shift (like CP export).
"""
import os, sys, warnings, torch, torch.nn as nn, torch.nn.functional as F
sys.path = [p for p in sys.path if 'Kazeia/executorch' not in p and p != '.']
os.environ['QNN_SDK_ROOT'] = os.environ.get('QNN_SDK_ROOT', '')
warnings.filterwarnings('ignore')
N_L = 28; N_H = 16; N_KV = 8; HD = 128; DIM = 1024; N_REP = 2
VOCAB = 3072; FFN = 3072
KV_LEN = 16 # Small KV for testing HTP viability
state = torch.load("/opt/Kazeia/models_qnn/qwen3-tts-export/qwen3_tts_talker.pth",
map_location="cpu", weights_only=False)
def rotate_half(x):
x1 = x[..., :x.shape[-1]//2]; x2 = x[..., x.shape[-1]//2:]
return torch.cat((-x2, x1), dim=-1)
def repeat_kv(x, n):
B, H, T, D = x.shape
return x[:, :, None, :, :].expand(B, H, n, T, D).reshape(B, H*n, T, D)
class RMSNorm(nn.Module):
def __init__(s, d):
super().__init__(); s.weight = nn.Parameter(torch.ones(d))
def forward(s, x):
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + 1e-6) * s.weight
class TalkerTransformer(nn.Module):
"""Talker transformer without codec_head (head done on CPU)."""
def __init__(s, st):
super().__init__()
s.na = nn.ModuleList(); s.nf = nn.ModuleList()
s.qp = nn.ModuleList(); s.kp = nn.ModuleList()
s.vp = nn.ModuleList(); s.op = nn.ModuleList()
s.qn = nn.ModuleList(); s.kn = nn.ModuleList()
s.ga = nn.ModuleList(); s.dn = nn.ModuleList(); s.up = nn.ModuleList()
for i in range(N_L):
p = f"layers.{i}."
a = RMSNorm(DIM); a.weight.data = st[p+"attention_norm.weight"]; s.na.append(a)
f = RMSNorm(DIM); f.weight.data = st[p+"ffn_norm.weight"]; s.nf.append(f)
s.qp.append(nn.Linear(DIM, N_H*HD, bias=False)); s.qp[-1].weight.data = st[p+"attention.wq.weight"]
s.kp.append(nn.Linear(DIM, N_KV*HD, bias=False)); s.kp[-1].weight.data = st[p+"attention.wk.weight"]
s.vp.append(nn.Linear(DIM, N_KV*HD, bias=False)); s.vp[-1].weight.data = st[p+"attention.wv.weight"]
s.op.append(nn.Linear(N_H*HD, DIM, bias=False)); s.op[-1].weight.data = st[p+"attention.wo.weight"]
q = RMSNorm(HD); q.weight.data = st[p+"attention.q_norm_fn.weight"]; s.qn.append(q)
k = RMSNorm(HD); k.weight.data = st[p+"attention.k_norm_fn.weight"]; s.kn.append(k)
s.ga.append(nn.Linear(DIM, FFN, bias=False)); s.ga[-1].weight.data = st[p+"feed_forward.w1.weight"]
s.dn.append(nn.Linear(FFN, DIM, bias=False)); s.dn[-1].weight.data = st[p+"feed_forward.w2.weight"]
s.up.append(nn.Linear(DIM, FFN, bias=False)); s.up[-1].weight.data = st[p+"feed_forward.w3.weight"]
s.fn = RMSNorm(DIM); s.fn.weight.data = st["norm.weight"]
# Include codec_head for CB0 prediction
s.head = nn.Linear(DIM, VOCAB, bias=False); s.head.weight.data = st["output.weight"]
def forward(s, emb, mask, cos, sin, *kv_args):
"""
emb: [1,1,1024]
mask: [1,1,1,KV_LEN]
cos: [1,1,128]
sin: [1,1,128]
kv: 28 × (k[1,8,KV_LEN,128], v[1,8,KV_LEN,128])
Returns: hidden[1,1,1024], logits[1,1,3072], 28 × (k[1,8,KV_LEN,128], v[1,8,KV_LEN,128])
"""
h = emb; c = cos.unsqueeze(1); sn = sin.unsqueeze(1)
nk = []
for i in range(N_L):
kc = kv_args[i*2]; vc = kv_args[i*2+1]
r = h; hn = s.na[i](h)
q = s.qp[i](hn).view(1,1,N_H,HD).transpose(1,2)
k = s.kp[i](hn).view(1,1,N_KV,HD).transpose(1,2)
v = s.vp[i](hn).view(1,1,N_KV,HD).transpose(1,2)
q = s.qn[i](q); k = s.kn[i](k)
q = q*c + rotate_half(q)*sn; k = k*c + rotate_half(k)*sn
# Shift KV: drop oldest, append new
kf = torch.cat([kc[:,:,1:,:], k], dim=2)
vf = torch.cat([vc[:,:,1:,:], v], dim=2)
ke = repeat_kv(kf, N_REP); ve = repeat_kv(vf, N_REP)
sc = torch.matmul(q, ke.transpose(-2,-1)) * (1.0/(HD**0.5)) + mask
ao = torch.matmul(F.softmax(sc, dim=-1), ve).transpose(1,2).contiguous().view(1,1,-1)
h = r + s.op[i](ao)
r = h; fn = s.nf[i](h)
h = r + s.dn[i](F.silu(s.ga[i](fn)) * s.up[i](fn))
nk.extend([kf, vf])
h = s.fn(h)
logits = s.head(h)
return (h, logits, *nk)
print("Building talker transformer...")
w = TalkerTransformer(state).eval()
n_params = sum(p.numel() for p in w.parameters())
print(f"Params: {n_params/1e6:.1f}M ({n_params*2/1024/1024:.0f}MB fp16)")
# Test
e = torch.randn(1,1,DIM)
m = torch.full((1,1,1,KV_LEN), -1e9); m[0,0,0,-1] = 0
inv = 1.0/(1e6**(torch.arange(0, HD, 2, dtype=torch.float32)/HD))
c0 = torch.cos(0*inv).repeat(2).unsqueeze(0).unsqueeze(0)
s0 = torch.sin(0*inv).repeat(2).unsqueeze(0).unsqueeze(0)
kvs = [torch.zeros(1, N_KV, KV_LEN, HD) for _ in range(N_L*2)]
with torch.no_grad():
out = w(e, m, c0, s0, *kvs)
print(f"Test: hidden={out[0].shape}, logits={out[1].shape}, kv0={out[2].shape}")
# ExecuTorch export
from executorch.backends.qualcomm.utils.utils import *
htp = generate_htp_compiler_spec(use_fp16=True)
bo = QnnExecuTorchBackendOptions(backend_type=QnnExecuTorchBackendType.kHtpBackend, htp_options=htp)
specs = generate_qnn_executorch_compiler_spec(soc_model=QcomChipset.SM8750, backend_options=bo)
print(f"Lowering talker transformer ({N_L} layers, KV={KV_LEN}) to QNN...")
edge = to_edge_transform_and_lower_to_qnn(w, (e, m, c0, s0, *kvs), compiler_specs=specs)
print("LOWERED!")
pte = edge.to_executorch()
OUT = "/opt/Kazeia/models_qnn/talker_transformer_fp16_kv16.pte"
with open(OUT, "wb") as f:
pte.write_to_file(f)
print(f"SAVED: {OUT} ({os.path.getsize(OUT)/1024/1024:.0f} MB)")

View File

@ -0,0 +1,74 @@
def _fix_qnn_json(json_str):
d = json.loads(json_str)
if "backend_options" in d:
bo = d["backend_options"]
if "htp_options" in bo and isinstance(bo["htp_options"], dict):
inner = bo["htp_options"]
if "htp_options" in inner and isinstance(inner["htp_options"], dict):
bo["htp_options"] = inner["htp_options"]
if "gpu_options" in bo and bo["gpu_options"] is None:
del bo["gpu_options"]
if "htp_options" in bo and isinstance(bo["htp_options"], dict):
bo["htp_options"].pop("gpu_options", None)
return json.dumps(d)
# Copyright (c) Qualcomm Innovation Center, Inc.
# Copyright 2025 Arm Limited and/or its affiliates.
# All rights reserved
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
import importlib.resources as _resources
import json
import os
import tempfile
import executorch.backends.qualcomm.serialization as serialization_package
from executorch.backends.qualcomm.serialization.qc_schema import QnnExecuTorchOptions
from executorch.exir._serialize._dataclass import _DataclassEncoder, _json_to_dataclass
from executorch.exir._serialize._flatbuffer import _flatc_compile, _flatc_decompile
def _convert_to_flatbuffer(obj, schema: str):
obj_json = _fix_qnn_json(json.dumps(obj, cls=_DataclassEncoder))
with tempfile.TemporaryDirectory() as d:
schema_path = os.path.join(d, f"{schema}.fbs")
with open(schema_path, "wb") as schema_file:
schema_file.write(
_resources.read_binary(serialization_package, f"{schema}.fbs")
)
json_path = os.path.join(d, f"{schema}.json")
with open(json_path, "wb") as json_file:
json_file.write(obj_json.encode("ascii"))
_flatc_compile(d, schema_path, json_path)
output_path = os.path.join(d, f"{schema}.bin")
with open(output_path, "rb") as output_file:
return output_file.read()
def _convert_to_object(flatbuffers: bytes, obj_type, schema: str):
with tempfile.TemporaryDirectory() as d:
json_path = os.path.join(d, f"{schema}.json")
schema_path = os.path.join(d, f"{schema}.fbs")
bin_path = os.path.join(d, f"{schema}.bin")
with open(schema_path, "wb") as schema_file:
schema_file.write(
_resources.read_binary(serialization_package, f"{schema}.fbs")
)
with open(bin_path, "wb") as bin_file:
bin_file.write(flatbuffers)
_flatc_decompile(d, schema_path, bin_path, ["--raw-binary"])
with open(json_path, "rb") as output_file:
return _json_to_dataclass(json.load(output_file), obj_type)
def option_to_flatbuffer(qnn_executorch_options: QnnExecuTorchOptions) -> bytes:
return _convert_to_flatbuffer(qnn_executorch_options, "qc_compiler_spec")
def flatbuffer_to_option(flatbuffers: bytes) -> QnnExecuTorchOptions:
return _convert_to_object(flatbuffers, QnnExecuTorchOptions, "qc_compiler_spec")

View File

@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Test CP ExecuTorch NPU quality vs Python reference.
1. Run full Python TTS pipeline, capturing CP inputs (hidden + cb0_emb)
2. Send those to CP ET runner on NPU via TCP (adb forward)
3. Compare NPU codes vs Python codes
4. Save both for tablet decoding
"""
import sys, os, struct, socket, time
os.chdir("/tmp") # avoid numpy import issues
import warnings; warnings.filterwarnings("ignore")
import torch
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."
CP_ET_PORT = 5556
print("Loading model...")
from qwen_tts import Qwen3TTSModel
tts = Qwen3TTSModel.from_pretrained(MODEL, local_files_only=True, device_map="cpu")
talker = tts.model.talker
cp = talker.code_predictor
# ── Monkey-patch code_predictor.generate to capture inputs + outputs ──
captured_frames = []
original_cp_generate = cp.generate.__func__ if hasattr(cp.generate, '__func__') else cp.generate
def patched_cp_generate(self_cp, **kwargs):
ie = kwargs.get("inputs_embeds")
# inputs_embeds shape: [1, 2, 1024] = [past_hidden, cb0_emb]
hidden = ie[0, 0, :].detach().cpu().numpy().astype(np.float32)
cb0_emb = ie[0, 1, :].detach().cpu().numpy().astype(np.float32)
result = original_cp_generate(self_cp, **kwargs)
# result.sequences shape: [1, 15] = CB1-CB15 codes
py_codes = result.sequences[0].tolist()
captured_frames.append({
"hidden": hidden,
"cb0_emb": cb0_emb,
"py_codes": py_codes, # CB1-CB15 from Python
})
return result
# Bind the patch
import types
cp.generate = types.MethodType(patched_cp_generate, cp)
# ── Run full Python pipeline ──
print(f"Generating: '{TEXT}'")
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"Python: {len(audio)} samples, {len(audio)/sr:.2f}s, {len(captured_frames)} frames captured")
import soundfile as sf
sf.write("/opt/Kazeia/kazeia_PY_REF.wav", audio, sr)
print("Saved: kazeia_PY_REF.wav")
# ── Extract CB0 codes from captured data ──
# CB0 comes from talker's codec_head, but we need it from the generation output.
# We can reverse-lookup from cb0_emb: find closest embedding in talker's embedding table.
talker_emb = talker.get_input_embeddings()
emb_weight = talker_emb.weight.detach().cpu().numpy() # [vocab_size, 1024]
cb0_codes = []
for frame in captured_frames:
# Find which embedding row matches cb0_emb
diffs = np.sum((emb_weight - frame["cb0_emb"]) ** 2, axis=1)
cb0 = int(np.argmin(diffs))
cb0_codes.append(cb0)
if diffs[cb0] > 1e-6:
print(f" WARNING: cb0 lookup imprecise, min_diff={diffs[cb0]:.6f}")
print(f"CB0 codes (first 5): {cb0_codes[:5]}")
# ── Send to CP ET runner on NPU ──
print(f"\nConnecting to CP ET runner on port {CP_ET_PORT}...")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(30)
sock.connect(("127.0.0.1", CP_ET_PORT))
print("Connected!")
npu_codes_all = []
total_npu_ms = 0
mismatches = 0
for i, frame in enumerate(captured_frames):
# Send hidden_in + cb0_emb = 2*1024*4 = 8192 bytes
payload = frame["hidden"].tobytes() + frame["cb0_emb"].tobytes()
sock.sendall(payload)
# Read response: 15 ints (60 bytes) + 1 float (4 bytes) = 64 bytes
resp = b""
while len(resp) < 64:
chunk = sock.recv(64 - len(resp))
if not chunk:
raise RuntimeError(f"Socket closed at frame {i}")
resp += chunk
npu_codes = list(struct.unpack("<15i", resp[:60]))
timing = struct.unpack("<f", resp[60:64])[0]
total_npu_ms += timing
npu_codes_all.append(npu_codes)
py_codes = frame["py_codes"]
match = sum(1 for a, b in zip(npu_codes, py_codes) if a == b)
if match < 15:
mismatches += 1
print(f" Frame {i:3d}: NPU {timing:6.1f}ms match {match:2d}/15 "
f"NPU={npu_codes[:4]} PY={py_codes[:4]}")
sock.close()
n = len(captured_frames)
print(f"\n{'='*60}")
print(f"RESULTS: {n} frames, NPU total {total_npu_ms:.0f}ms ({total_npu_ms/n:.1f}ms/frame)")
print(f"Mismatches: {mismatches}/{n} frames ({100*mismatches/n:.0f}%)")
# ── Save codes for tablet decoding ──
# Format: binary, n_frames * 16 int32 (CB0, CB1..CB15)
codes_py = []
codes_npu = []
for i in range(n):
codes_py.append([cb0_codes[i]] + captured_frames[i]["py_codes"])
codes_npu.append([cb0_codes[i]] + npu_codes_all[i])
py_path = "/opt/Kazeia/test_codes_python.bin"
npu_path = "/opt/Kazeia/test_codes_npu.bin"
with open(py_path, "wb") as f:
f.write(struct.pack("<i", n))
for frame_codes in codes_py:
f.write(struct.pack(f"<{len(frame_codes)}i", *frame_codes))
with open(npu_path, "wb") as f:
f.write(struct.pack("<i", n))
for frame_codes in codes_npu:
f.write(struct.pack(f"<{len(frame_codes)}i", *frame_codes))
print(f"\nSaved: {py_path} ({os.path.getsize(py_path)} bytes)")
print(f"Saved: {npu_path} ({os.path.getsize(npu_path)} bytes)")
print(f"\nNext: push to tablet and decode with V2 decoder")