From 389ffa7c61bb57ad3ded4957c536f13d6ff58303 Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Thu, 9 Apr 2026 08:42:11 +0200 Subject: [PATCH] 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) --- .gitignore | 85 + AI_HUB_QUALCOMM.md | 2 + ARCHITECTURE_PIPELINE.md | 193 + AVATAR_3D_RAPPORT.md | 691 ++ BENCHMARK_RAPPORT.md | 416 ++ BENCHMARK_ROOT_VS_NONROOT.md | 200 + DEPLOY_EXECUTORCH_NPU.md | 138 + DOCUMENTATION_KAZEIA.txt | 913 +++ GUIDE_ROOT_ONEPLUS_PAD3.md | 303 + KAZEIA-CLAUDE.md | 6364 +++++++++++++++++ RAPPORT_TTS.md | 253 + TTS_CALIBRATION_GUIDE.md | 151 + TTS_GPU_GUIDE.md | 154 + TTS_HEXAGON_NPU_GUIDE.md | 128 + TTS_RAPPORT_COMPLET.md | 277 + TTS_REPORT.md | 177 + kazeia-android/COMPILE_WHISPER_NPU.md | 184 + kazeia-android/RAPPORT_TTS_NPU.md | 243 + kazeia-android/RAPPORT_TTS_QWEN3_TESTS.md | 135 + kazeia-android/app/build.gradle.kts | 100 + kazeia-android/app/proguard-rules.pro | 3 + .../app/src/main/AndroidManifest.xml | 61 + .../app/src/main/assets/voice_commands.json | 88 + .../main/java/com/kazeia/KazeiaApplication.kt | 37 + .../com/kazeia/audio/AudioCaptureManager.kt | 113 + .../com/kazeia/audio/AudioPlaybackManager.kt | 54 + .../conversation/ConversationManager.kt | 21 + .../com/kazeia/conversation/EchoProcessor.kt | 19 + .../com/kazeia/conversation/LlmProcessor.kt | 58 + .../com/kazeia/conversation/PromptBuilder.kt | 12 + .../kazeia/conversation/StoppingCriteria.kt | 26 + .../conversation/VoiceCommandProcessor.kt | 111 + .../conversation/VoiceCommandProcessor2.kt | 34 + .../java/com/kazeia/core/ConversationState.kt | 23 + .../main/java/com/kazeia/core/LlmEngine.kt | 33 + .../src/main/java/com/kazeia/core/Pipeline.kt | 62 + .../main/java/com/kazeia/core/SttEngine.kt | 18 + .../main/java/com/kazeia/core/TtsEngine.kt | 24 + .../main/java/com/kazeia/core/VadEngine.kt | 11 + .../com/kazeia/llm/ExecuTorchLlmEngine.kt | 228 + .../src/main/java/com/kazeia/llm/GenieJni.kt | 53 + .../java/com/kazeia/llm/GenieLlmEngine.kt | 74 + .../java/com/kazeia/service/KazeiaPipeline.kt | 161 + .../java/com/kazeia/service/KazeiaService.kt | 1070 +++ .../java/com/kazeia/stt/AndroidSttEngine.kt | 155 + .../main/java/com/kazeia/stt/MelExtractor.kt | 18 + .../com/kazeia/stt/WhisperHybridEngine.kt | 542 ++ .../main/java/com/kazeia/stt/WhisperJni.kt | 16 + .../com/kazeia/stt/WhisperLiteRtEngine.kt | 305 + .../com/kazeia/stt/WhisperNpuSttEngine.kt | 165 + .../java/com/kazeia/stt/WhisperSttEngine.kt | 72 + .../java/com/kazeia/tts/AndroidTtsEngine.kt | 99 + .../com/kazeia/tts/ChatterboxTtsEngine.kt | 499 ++ .../java/com/kazeia/tts/Qwen3TtsEngine.kt | 2566 +++++++ .../src/main/java/com/kazeia/ui/AudioTest.kt | 99 + .../main/java/com/kazeia/ui/ChatActivity.kt | 441 ++ .../main/java/com/kazeia/ui/ChatAdapter.kt | 68 + .../main/java/com/kazeia/ui/MiniGraphView.kt | 117 + .../java/com/kazeia/ui/ResourceMonitor.kt | 145 + .../main/java/com/kazeia/ui/SplashActivity.kt | 142 + .../java/com/kazeia/vad/SileroVadEngine.kt | 118 + .../app/src/main/jni/CMakeLists.txt | 43 + kazeia-android/app/src/main/jni/genie_jni.cpp | 201 + .../app/src/main/jni/mel_extractor.cpp | 202 + .../app/src/main/jni/whisper_jni.cpp | 156 + .../main/res/drawable/bg_bubble_kazeia.xml | 10 + .../main/res/drawable/bg_bubble_patient.xml | 10 + .../src/main/res/drawable/bg_chat_panel.xml | 5 + .../main/res/drawable/bg_chat_panel_land.xml | 6 + .../src/main/res/drawable/bg_drag_handle.xml | 6 + .../app/src/main/res/drawable/bg_input.xml | 7 + .../src/main/res/drawable/bg_mic_button.xml | 6 + .../main/res/drawable/bg_mic_listening.xml | 6 + .../main/res/drawable/bg_overlay_button.xml | 9 + .../src/main/res/drawable/bg_quit_button.xml | 5 + .../app/src/main/res/layout/activity_chat.xml | 270 + .../src/main/res/layout/activity_splash.xml | 66 + .../app/src/main/res/layout/item_message.xml | 46 + .../app/src/main/res/layout/overlay_main.xml | 271 + .../src/main/res/mipmap-hdpi/ic_launcher.xml | 5 + .../app/src/main/res/values/colors.xml | 13 + .../app/src/main/res/values/strings.xml | 17 + .../app/src/main/res/values/themes.xml | 16 + kazeia-android/build.gradle.kts | 4 + kazeia-android/gradle.properties | 7 + .../gradle/wrapper/gradle-wrapper.properties | 7 + kazeia-android/gradlew | 251 + kazeia-android/gradlew.bat | 94 + kazeia-android/settings.gradle.kts | 23 + kazeia-architecture.md | 830 +++ scripts/cp_et_runner.cpp | 207 + scripts/export_cp_pte.py | 67 + scripts/export_talker_pte.py | 123 + scripts/qc_schema_serialize_patched.py | 74 + scripts/test_cp_et_quality.py | 148 + 95 files changed, 22279 insertions(+) create mode 100644 .gitignore create mode 100644 AI_HUB_QUALCOMM.md create mode 100644 ARCHITECTURE_PIPELINE.md create mode 100644 AVATAR_3D_RAPPORT.md create mode 100644 BENCHMARK_RAPPORT.md create mode 100644 BENCHMARK_ROOT_VS_NONROOT.md create mode 100644 DEPLOY_EXECUTORCH_NPU.md create mode 100644 DOCUMENTATION_KAZEIA.txt create mode 100644 GUIDE_ROOT_ONEPLUS_PAD3.md create mode 100644 KAZEIA-CLAUDE.md create mode 100644 RAPPORT_TTS.md create mode 100644 TTS_CALIBRATION_GUIDE.md create mode 100644 TTS_GPU_GUIDE.md create mode 100644 TTS_HEXAGON_NPU_GUIDE.md create mode 100644 TTS_RAPPORT_COMPLET.md create mode 100644 TTS_REPORT.md create mode 100644 kazeia-android/COMPILE_WHISPER_NPU.md create mode 100644 kazeia-android/RAPPORT_TTS_NPU.md create mode 100644 kazeia-android/RAPPORT_TTS_QWEN3_TESTS.md create mode 100644 kazeia-android/app/build.gradle.kts create mode 100644 kazeia-android/app/proguard-rules.pro create mode 100644 kazeia-android/app/src/main/AndroidManifest.xml create mode 100644 kazeia-android/app/src/main/assets/voice_commands.json create mode 100644 kazeia-android/app/src/main/java/com/kazeia/KazeiaApplication.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/audio/AudioCaptureManager.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/audio/AudioPlaybackManager.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/conversation/ConversationManager.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/conversation/EchoProcessor.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/conversation/LlmProcessor.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/conversation/PromptBuilder.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/conversation/StoppingCriteria.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor2.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/core/ConversationState.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/core/Pipeline.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/core/SttEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/core/VadEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/llm/ExecuTorchLlmEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/llm/GenieJni.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/llm/GenieLlmEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/stt/AndroidSttEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/stt/MelExtractor.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/stt/WhisperJni.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/stt/WhisperLiteRtEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/stt/WhisperNpuSttEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/stt/WhisperSttEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/tts/AndroidTtsEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/tts/ChatterboxTtsEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/ui/AudioTest.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/ui/ChatAdapter.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/ui/MiniGraphView.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/ui/ResourceMonitor.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/ui/SplashActivity.kt create mode 100644 kazeia-android/app/src/main/java/com/kazeia/vad/SileroVadEngine.kt create mode 100644 kazeia-android/app/src/main/jni/CMakeLists.txt create mode 100644 kazeia-android/app/src/main/jni/genie_jni.cpp create mode 100644 kazeia-android/app/src/main/jni/mel_extractor.cpp create mode 100644 kazeia-android/app/src/main/jni/whisper_jni.cpp create mode 100644 kazeia-android/app/src/main/res/drawable/bg_bubble_kazeia.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_bubble_patient.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_chat_panel.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_chat_panel_land.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_drag_handle.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_input.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_mic_button.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_mic_listening.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_overlay_button.xml create mode 100644 kazeia-android/app/src/main/res/drawable/bg_quit_button.xml create mode 100644 kazeia-android/app/src/main/res/layout/activity_chat.xml create mode 100644 kazeia-android/app/src/main/res/layout/activity_splash.xml create mode 100644 kazeia-android/app/src/main/res/layout/item_message.xml create mode 100644 kazeia-android/app/src/main/res/layout/overlay_main.xml create mode 100644 kazeia-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 kazeia-android/app/src/main/res/values/colors.xml create mode 100644 kazeia-android/app/src/main/res/values/strings.xml create mode 100644 kazeia-android/app/src/main/res/values/themes.xml create mode 100644 kazeia-android/build.gradle.kts create mode 100644 kazeia-android/gradle.properties create mode 100644 kazeia-android/gradle/wrapper/gradle-wrapper.properties create mode 100755 kazeia-android/gradlew create mode 100644 kazeia-android/gradlew.bat create mode 100644 kazeia-android/settings.gradle.kts create mode 100644 kazeia-architecture.md create mode 100644 scripts/cp_et_runner.cpp create mode 100644 scripts/export_cp_pte.py create mode 100644 scripts/export_talker_pte.py create mode 100644 scripts/qc_schema_serialize_patched.py create mode 100644 scripts/test_cp_et_quality.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ea0e2e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AI_HUB_QUALCOMM.md b/AI_HUB_QUALCOMM.md new file mode 100644 index 0000000..8cb22d7 --- /dev/null +++ b/AI_HUB_QUALCOMM.md @@ -0,0 +1,2 @@ +E-Mail: support@kazeia.com +API Token: vlwls2lyair3f15c98il8g7xsm8g6377zuj2mthc diff --git a/ARCHITECTURE_PIPELINE.md b/ARCHITECTURE_PIPELINE.md new file mode 100644 index 0000000..e1e5232 --- /dev/null +++ b/ARCHITECTURE_PIPELINE.md @@ -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, // historique conversation + val metadata: MutableMap, // 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* diff --git a/AVATAR_3D_RAPPORT.md b/AVATAR_3D_RAPPORT.md new file mode 100644 index 0000000..4bef797 --- /dev/null +++ b/AVATAR_3D_RAPPORT.md @@ -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 | diff --git a/BENCHMARK_RAPPORT.md b/BENCHMARK_RAPPORT.md new file mode 100644 index 0000000..14c2782 --- /dev/null +++ b/BENCHMARK_RAPPORT.md @@ -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 ` + +#### 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)* diff --git a/BENCHMARK_ROOT_VS_NONROOT.md b/BENCHMARK_ROOT_VS_NONROOT.md new file mode 100644 index 0000000..ee54873 --- /dev/null +++ b/BENCHMARK_ROOT_VS_NONROOT.md @@ -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* diff --git a/DEPLOY_EXECUTORCH_NPU.md b/DEPLOY_EXECUTORCH_NPU.md new file mode 100644 index 0000000..4741ec4 --- /dev/null +++ b/DEPLOY_EXECUTORCH_NPU.md @@ -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* diff --git a/DOCUMENTATION_KAZEIA.txt b/DOCUMENTATION_KAZEIA.txt new file mode 100644 index 0000000..408c4dc --- /dev/null +++ b/DOCUMENTATION_KAZEIA.txt @@ -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/ | GET | Resume de conversation | +| /get_conversation/ | GET | Historique de conversation | +| /get_chatbot_response | POST | Obtenir reponse du chatbot | +| /patient/ | 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/ +---------------------------------- +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 +================================================================================ diff --git a/GUIDE_ROOT_ONEPLUS_PAD3.md b/GUIDE_ROOT_ONEPLUS_PAD3.md new file mode 100644 index 0000000..7f744a7 --- /dev/null +++ b/GUIDE_ROOT_ONEPLUS_PAD3.md @@ -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* diff --git a/KAZEIA-CLAUDE.md b/KAZEIA-CLAUDE.md new file mode 100644 index 0000000..be0e08f --- /dev/null +++ b/KAZEIA-CLAUDE.md @@ -0,0 +1,6364 @@ +You asked: +---------- + +Tu connais le projet Kazeia, maintenant l'idée est de voir comment optimiser l'entrainement d'un modèle LLM pour qu'il soit le plus "compréhensif" possible, sinon peux tu proposer des pistes ? + +Mar 11 + +--- + +Claude Replied: +--------------- + +Orchestrated empathy optimization strategies for therapeutic LLM training + +Orchestrated empathy optimization strategies for therapeutic LLM training + +Bonne question Richard, c'est vraiment le cœur du projet. Voici les pistes principales pour maximiser la qualité empathique du modèle Kazeia. + +**La donnée d'entraînement est le levier n°1.** Un petit modèle bien entraîné sur des données de haute qualité battra un gros modèle mal alimenté. Concrètement : + +Privilégie des **corpus de conversations thérapeutiques réelles** (anonymisées) ou des transcriptions d'entretiens motivationnels. Des datasets comme _Counsel Chat_, _EmpatheticDialogues_ (Facebook Research), ou _HOPE_ (annotés en stratégies thérapeutiques) sont de bonnes bases. Le défi pour toi c'est le français — il faudra soit traduire ces datasets avec un modèle de qualité, soit constituer un corpus francophone, par exemple à partir de transcriptions de lignes d'écoute (avec accord éthique évidemment). + +**La structure des données compte autant que le volume.** Plutôt que des paires question/réponse brutes, structure tes exemples avec des annotations : l'émotion détectée, la stratégie de réponse utilisée (validation, reformulation, question ouverte, normalisation…), et le niveau de sévérité. Ça permet au modèle d'apprendre _pourquoi_ une réponse est empathique, pas juste de la mimer. + +**Le fine-tuning supervisé (SFT) seul ne suffit pas** pour un usage thérapeutique. Deux approches complémentaires : + +Le **DPO (Direct Preference Optimization)** est probablement ta meilleure option vu les contraintes du projet. Tu crées des paires de réponses (une empathique, une maladroite) pour une même situation, et le modèle apprend à préférer la bonne. C'est plus simple à implémenter que du RLHF classique et ça fonctionne bien sur des petits modèles comme Qwen. Concrètement tu peux générer les réponses "mauvaises" en dégradant volontairement les bonnes (retirer la validation émotionnelle, donner des conseils non sollicités, minimiser la souffrance…). + +Le **RLHF** avec des retours de professionnels de santé serait l'idéal à terme, mais c'est lourd à mettre en place. Garde-le pour une phase ultérieure si vous obtenez un partenariat clinique. + +**Quelques techniques spécifiques qui font la différence :** + +Sur le plan du prompting et du format d'entraînement, intègre un "chain of thought empathique" dans tes données : avant de formuler sa réponse, le modèle identifie d'abord l'émotion, puis la stratégie appropriée, puis génère la réponse. À l'inférence tu peux masquer ce raisonnement intermédiaire mais il améliore nettement la qualité. + +Pour l'évaluation, ne te fie pas uniquement à la perplexité. Mets en place des métriques spécifiques : détection correcte de l'émotion, présence de validation émotionnelle dans la réponse, absence de conseils non sollicités, et surtout des évaluations humaines par des psychologues sur un jeu de test fixe. + +**Un point important vu que tu cibles un modèle compact pour Android offline** : les techniques de distillation de connaissances peuvent être très efficaces. Tu peux utiliser un gros modèle (Claude, GPT-4…) pour générer un corpus synthétique de haute qualité de conversations empathiques en français, puis fine-tuner ton Qwen dessus. C'est un raccourci puissant pour compenser la rareté des données francophones. + +Enfin, pense à l'**apprentissage par curriculum** : entraîne d'abord sur des conversations simples (émotions légères, réponses directes), puis augmente progressivement la complexité (crises, ambivalence, idéation suicidaire). Ça stabilise l'apprentissage et améliore la gestion des cas difficiles. + +Tu veux qu'on creuse un de ces axes en particulier, ou qu'on regarde comment structurer concrètement le pipeline d'entraînement avec les outils que tu as déjà ? + +--- + +You asked: +---------- + +est ce que l'utilisation d'un modèle de detection d'emotion pourrait améliorer son fonctionnement et ces réponses ? + +Mar 11 + +--- + +Claude Replied: +--------------- + +Évalué le potentiel thérapeutique de la détection émotionnelle + +Évalué le potentiel thérapeutique de la détection émotionnelle + +Oui, clairement. Un modèle de détection d'émotion dédié peut apporter un vrai gain, surtout dans un contexte thérapeutique où mal interpréter l'état émotionnel du patient a des conséquences directes sur la qualité de l'accompagnement. + +**Le problème actuel** avec ton architecture, c'est que le LLM Qwen fait tout en même temps : il doit comprendre l'émotion, choisir la stratégie de réponse, et générer le texte. Sur un petit modèle destiné à tourner offline sur tablette Android, c'est beaucoup demander. En déchargeant la détection d'émotion sur un modèle spécialisé, tu permets au LLM de se concentrer sur ce qu'il fait le mieux : générer une réponse adaptée à partir d'une émotion déjà identifiée. + +**Concrètement, l'architecture deviendrait un pipeline en deux étapes.** Le message du patient passe d'abord par le classifieur d'émotions, qui produit une étiquette (tristesse, anxiété, colère, joie, désespoir…) avec un score de confiance. Cette information est ensuite injectée dans le prompt du LLM, par exemple : _"L'utilisateur exprime de la tristesse (confiance : 0.87) avec des indices d'anxiété secondaire (0.42). Adapte ta réponse en conséquence."_ Le LLM n'a plus à deviner, il sait. + +**Pour le choix du modèle**, en français et avec ta contrainte offline sur Android, plusieurs options : + +CamemBERT fine-tuné sur de la détection d'émotion est le candidat naturel. C'est un modèle BERT francophone, relativement léger (environ 110M de paramètres pour la base), et il existe déjà des versions fine-tunées sur des tâches de sentiment. Tu devrais le spécialiser davantage sur des émotions thérapeutiques — les taxonomies classiques (Ekman, Plutchik) sont trop grossières pour ton usage. Définis plutôt une taxonomie adaptée au contexte clinique : tristesse, anxiété, colère, honte, culpabilité, désespoir, solitude, frustration, soulagement, espoir, gratitude, et un état neutre. + +En alternative plus légère, un DistilCamemBERT ou un modèle encore plus compact type TinyBERT distillé ferait environ 30-60M de paramètres, ce qui est très confortable pour une tablette Android via ONNX Runtime. + +**Les gains concrets que tu peux en attendre :** + +Sur la qualité des réponses, le LLM ne confondra plus un patient en colère avec un patient triste. C'est un problème fréquent avec les petits modèles — ils captent le ton négatif mais nuancent mal. Or la réponse thérapeutique appropriée est très différente : valider la colère versus accompagner la tristesse. + +Sur le PHQ-9, tu peux utiliser le classifieur pour détecter des signaux d'alerte entre les évaluations formelles. Si le modèle détecte une montée progressive du désespoir sur plusieurs conversations, tu peux proposer le PHQ-9 de manière anticipée plutôt que d'attendre les 7 jours configurés. C'est un vrai plus pour la sécurité du patient. + +Sur l'avatar 3D que vous développez, l'émotion détectée peut directement piloter les expressions faciales et le ton de la voix synthétisée. Au lieu que l'avatar ait une expression générique, il peut montrer de l'inquiétude quand le patient est triste, de la chaleur quand il exprime de la gratitude. Ça renforce considérablement l'alliance thérapeutique. + +**Un point d'attention important** : ne te repose pas uniquement sur le texte. Dans un contexte thérapeutique, l'émotion exprimée verbalement et l'émotion réelle divergent souvent. Le patient dit "ça va" mais son ton de voix dit le contraire. Puisque tu as déjà Whisper dans ton pipeline pour le STT, tu pourrais envisager une détection d'émotion **multimodale** — une branche analyse le texte transcrit, une autre analyse les caractéristiques acoustiques (prosodie, débit, pauses, tremblements) directement depuis l'audio. La fusion des deux signaux est bien plus fiable que le texte seul. Des modèles comme Wav2Vec2 fine-tuné sur de la reconnaissance d'émotion vocale pourraient servir de base pour la branche audio, et ça reste portable sur Android via ONNX. + +**Le coût en termes d'implémentation** est raisonnable. Un modèle BERT-like pour la classification d'émotion, c'est une inférence de quelques millisecondes sur tablette — négligeable comparé aux 1500ms de ton LLM. L'empreinte mémoire supplémentaire serait d'environ 200-400 Mo selon le modèle choisi, ce qui reste gérable. + +Tu veux qu'on détaille le pipeline multimodal texte + audio, ou qu'on se concentre d'abord sur la mise en place du classifieur texte seul ? + +--- + +You asked: +---------- + +justement existe t'il un STT qui permette aussi de faire de la detection d'emotion ? + +Mar 11 + +--- + +Claude Replied: +--------------- + +Investigated multimodal STT models combining speech recognition with emotion detection + +Investigated multimodal STT models combining speech recognition with emotion detection + +Bonne intuition — combiner les deux dans un seul modèle simplifierait ton pipeline et éviterait de faire tourner deux modèles séparés sur la tablette. + +**La réponse courte : il n'existe pas vraiment de modèle unique clé en main qui fasse les deux parfaitement**, mais il y a des approches très prometteuses qui s'en rapprochent. + +**Le candidat le plus sérieux, c'est Whisper lui-même, fine-tuné.** Whisper encode l'audio dans des représentations internes très riches qui captent bien plus que le texte — elles contiennent implicitement des informations prosodiques (intonation, débit, énergie, pauses). L'idée serait de récupérer les embeddings de l'encoder de Whisper et d'ajouter une tête de classification d'émotion par-dessus, en plus de la tête de décodage texte existante. Concrètement tu obtiens un modèle multi-tâche : une entrée audio, deux sorties (transcription + émotion). L'avantage est énorme pour toi — un seul passage d'inférence, un seul modèle à déployer sur la tablette. + +En pratique, des travaux de recherche ont montré que ça fonctionne bien. Le papier _"Exploring Wav2Vec 2.0 and Whisper for Speech Emotion Recognition"_ montre que les features de Whisper sont compétitives pour la SER (Speech Emotion Recognition). Tu gardes l'encoder Whisper gelé ou partiellement fine-tuné, tu branches un petit classifieur (quelques couches linéaires) sur les représentations intermédiaires, et tu entraînes sur un dataset d'émotion vocale. + +**Pour les datasets d'entraînement de la partie émotion**, en français c'est le point délicat. Quelques ressources existent : RAVDESS et IEMOCAP sont les références mais en anglais. En français, tu as **RECOLA** (interactions spontanées annotées en émotion continue) et **AlloSat** (satisfaction dans des conversations téléphoniques). C'est limité, donc une stratégie réaliste serait d'entraîner d'abord sur les gros datasets anglais puis de fine-tuner sur le peu de données françaises disponibles — le transfer learning fonctionne bien pour les émotions vocales car la prosodie émotionnelle est en grande partie universelle (la tristesse "sonne" pareil dans beaucoup de langues). + +**L'alternative plus mature technologiquement, c'est Wav2Vec2.** Il est davantage utilisé que Whisper pour la SER dans la littérature. Des modèles comme **ehcalabres/wav2vec2-lg-xlsr-en-speech-emotion-recognition** sur HuggingFace font déjà de la détection d'émotion directement depuis l'audio. Le souci c'est que ce n'est _que_ de la détection d'émotion, pas du STT. Donc tu te retrouves avec deux modèles séparés. Cela dit, Wav2Vec2 et Whisper partagent une architecture encoder similaire, et il existe des travaux sur le partage de l'encoder entre les deux tâches. + +**Pour ton cas spécifique sur Android offline, voici ce que je recommanderais :** + +L'approche la plus pragmatique serait de prendre Whisper small (que tu utilises déjà), d'extraire les embeddings de son encoder, et d'entraîner un petit classifieur d'émotion à côté. À l'inférence, l'audio passe une seule fois dans l'encoder Whisper, puis les représentations partent vers deux branches : le décodeur pour la transcription et le classifieur pour l'émotion. Le surcoût en temps d'inférence est quasi nul (le classifieur c'est quelques millisecondes), et tu n'ajoutes que quelques Mo de poids supplémentaires. + +Le pipeline ressemblerait à ça : + + Audio patient + │ + ▼ + ┌─────────────┐ + │ Whisper │ + │ Encoder │ ← un seul passage + └──────┬──────┘ + │ + ┌───┴───┐ + ▼ ▼ + ┌──────┐ ┌──────────┐ + │Decoder│ │Classifieur│ + │ STT │ │ Émotion │ + └──┬───┘ └────┬─────┘ + │ │ + ▼ ▼ + Texte Émotion (tristesse 0.82, anxiété 0.35...) + │ │ + └────┬─────┘ + ▼ + Prompt LLM enrichi + +**Un point à ne pas négliger** : au-delà de l'émotion catégorielle (triste, anxieux, en colère), les caractéristiques acoustiques brutes sont aussi très informatives en contexte clinique. Un débit de parole qui ralentit sur plusieurs séances, des pauses de plus en plus longues, une énergie vocale qui baisse — ce sont des marqueurs que les psychiatres utilisent intuitivement. Tu pourrais extraire ces features en parallèle de la classification d'émotion, toujours depuis le même encoder Whisper, et les logger pour que le médecin ait accès à une vue longitudinale via l'interface soignant. Ce serait un vrai différenciateur pour Kazeia par rapport aux chatbots classiques. + +Tu veux qu'on détaille l'implémentation technique du fine-tuning de Whisper en multi-tâche, ou qu'on regarde d'abord quels datasets seraient exploitables pour entraîner la partie émotion en français ? + +--- + +You asked: +---------- + +SI on commence à utiliser des modèles différents j'en viens à me demander quelle plateforme nous pourrions utiliser pour Kazeia ? Pourrions nous utiliser directement un iPad avec une puce M4 ? (mais je ne sais pas ce qu'il a comme RAM) + +Mar 11 + +--- + +Claude Replied: +--------------- + +Investigated iPad M4 feasibility and RAM specifications for Kazeia + +Investigated iPad M4 feasibility and RAM specifications for Kazeia + +Bonne question — le choix de la plateforme matérielle est déterminant vu le nombre de modèles qu'on empile. Laisse-moi vérifier les specs actuelles des iPad M4. + +Synthesized information pour évaluer compatibilité iPad M4 + +Synthesized information pour évaluer compatibilité iPad M4 + +C'est une question très pertinente. Voici ce que ça donne concrètement. + +**Côté RAM sur l'iPad Pro M4 :** les modèles 256 Go et 512 Go ont 8 Go de RAM, tandis que les versions 1 To et 2 To montent à 16 Go [EveryiPad.com](https://everymac.com/systems/apple/ipad/specs/apple-ipad-pro-m4-13-inch-2024-a2925-wifi-only-specs.html). Il y a même un détail intéressant : le nouvel iPad Air M4 (mars 2026) embarque 12 Go de RAM en standard [Macworld](https://www.macworld.com/article/3055278/2026-m4-ipad-air-design-display-specs-release-price.html). Donc selon la config, tu as entre 8 et 16 Go de mémoire unifiée avec 120 Go/s de bande passante mémoire [Wikipedia](https://en.wikipedia.org/wiki/Apple_M4), plus un Neural Engine 16 cœurs. + +**Est-ce que c'est suffisant pour Kazeia ?** Oui, mais avec des compromis importants. Faisons le calcul de ton pipeline complet : + +Pour le LLM (Qwen fine-tuné), en INT4 quantisé un modèle ~1.7B paramètres pèse environ 1-1.5 Go en mémoire. C'est confortable. Pour Whisper small (ton STT), c'est environ 500 Mo. Le classifieur d'émotion (CamemBERT/DistilCamemBERT) ajouterait 200-400 Mo. Piper TTS est très léger, environ 50-100 Mo. L'avatar Unity consommerait 500 Mo à 1 Go selon la complexité. Au total, tu arrives à environ 3-4 Go de RAM pour tout le pipeline, ce qui laisse de la marge même sur un iPad 8 Go. + +**Le vrai avantage de l'iPad M4 par rapport à Android, c'est l'écosystème d'inférence.** Apple a publié des guides détaillés pour déployer des LLM via Core ML sur Apple Silicon, avec des optimisations spécifiques comme la quantization INT4 et le KV cache stateful [Apple Machine Learning Research](https://machinelearning.apple.com/research/core-ml-on-device-llama). Whisper.cpp supporte nativement le Neural Engine via Core ML, avec des accélérations de plus de 3x par rapport au CPU seul [GitHub](https://github.com/ggml-org/whisper.cpp). Et des projets récents montrent qu'on peut faire tourner Qwen 3, Llama 3.2 et d'autres modèles GGUF sur iOS avec 15 à 30 tokens par seconde via llama.cpp et Metal [DEV Community](https://dev.to/alichherawalla/how-to-run-llms-locally-on-your-iphone-in-2026-completely-offline-no-subscription-4b3a). C'est significativement plus rapide que tes 30-50 tokens/s actuels en HuggingFace sur PC. + +**Cela dit, il y a des inconvénients majeurs à considérer :** + +Le **développement est beaucoup plus contraint sur iOS/iPadOS**. Tu passes de Python (Flask, PyTorch, ChromaDB) à Swift/Objective-C avec Core ML. C'est une réécriture quasi complète. Sur Android tu avais au moins la possibilité d'utiliser des runtimes comme ONNX Runtime ou llama.cpp directement. Sur iPad aussi d'ailleurs, via llama.cpp et Metal, mais l'intégration Unity + Core ML + llama.cpp demande un travail d'ingénierie conséquent. + +La **distribution est verrouillée par l'App Store**. Pour un dispositif médical déployé en hôpital, c'est un vrai problème. Apple a un programme Enterprise et MDM (Mobile Device Management) qui permet de déployer des apps en interne, mais c'est plus complexe qu'un APK Android qu'on sideload sur une tablette. + +Le **coût matériel** est bien supérieur. Un iPad Pro M4 c'est 1200-2000€ selon la config, contre 300-600€ pour une tablette Android performante avec un bon SoC (type Samsung Galaxy Tab S9 avec Snapdragon 8 Gen 2). + +**Ma recommandation pour ton cas :** l'iPad M4 est techniquement séduisant, mais pour un déploiement hospitalier en contexte médical validé, Android reste plus pragmatique pour plusieurs raisons : coût par unité nettement inférieur (tu vas déployer sur plusieurs services), distribution plus simple en environnement contrôlé, et l'écosystème Android pour l'inférence ML a énormément progressé (ONNX Runtime Mobile, TFLite, llama.cpp avec Vulkan). Les Snapdragon récents ont aussi des NPU performants. + +Par contre, si vous avez le budget et que le parc est limité (quelques tablettes pilotes), l'iPad M4 avec 16 Go donnerait une expérience utilisateur plus fluide et plus fiable grâce à la standardisation du hardware Apple — pas de fragmentation comme sur Android. + +Tu veux qu'on compare les performances concrètes du pipeline complet sur les deux plateformes, ou qu'on explore la question du déploiement MDM en milieu hospitalier ? + +--- + +You asked: +---------- + +OK, alors quel serait le meilleur candidat pour une tablette, non Apple + +Mar 11 + +--- + +Claude Replied: +--------------- + +Investigated optimal Android tablet specifications for enterprise deployment + +Investigated optimal Android tablet specifications for enterprise deployment + +Bonne question, il faut trouver le bon équilibre entre puissance de calcul (NPU + GPU), RAM, et viabilité pour un déploiement médical. Laisse-moi vérifier ce qui est disponible actuellement. + +Rassemblé données pour formuler recommandation tablette + +Rassemblé données pour formuler recommandation tablette + +Excellent, j'ai maintenant une vue claire. Voici mon analyse. + +**Le meilleur candidat pour Kazeia, c'est le OnePlus Pad 3.** Et de loin pour ton cas d'usage. Voici pourquoi. + +Il embarque le Snapdragon 8 Elite avec 12 ou 16 Go de RAM [GSMArena](https://www.gsmarena.com/oneplus_pad_3_\(global\)-13881.php), à un prix de 699$ pour la version de base [9to5Google](https://9to5google.com/2025/06/05/oneplus-pad-3-specs-price-biometrics/). Le Snapdragon 8 Elite est crucial pour toi parce que son NPU Hexagon est coté à 75 TOPS, contre 45 TOPS pour la génération précédente, et il supporte l'inférence on-device pour des modèles jusqu'à 7B paramètres [Technosports](https://technosports.co.in/ai-chips-2026-the-breakthrough-processors/). Pour ton Qwen ~1.7B, c'est largement dimensionné. + +En termes de performances concrètes pour l'inférence LLM, Qualcomm a mesuré environ 18 tokens/seconde sur le Snapdragon 8 Elite pour un Llama 2 7B quantisé en 4 bits [Hugging Face](https://huggingface.co/qualcomm/Llama-v2-7B-Chat). Ton modèle Qwen étant beaucoup plus petit (~1.7B), tu peux raisonnablement espérer 40-60 tokens/seconde, ce qui est bien supérieur à tes 30-50 tokens/s actuels sur PC avec HuggingFace. + +**Pourquoi le OnePlus Pad 3 plutôt que le Samsung Galaxy Tab S10 Ultra ?** Le Tab S10 Ultra a un Dimensity 9300+, et c'est la première fois que Samsung utilise MediaTek dans sa gamme Tab S au lieu de Snapdragon [Wikipedia](https://en.wikipedia.org/wiki/Samsung_Galaxy_Tab_S10). Le problème, c'est que l'écosystème logiciel pour l'inférence ML est nettement plus mature sur Qualcomm. Les NPU Hexagon de Qualcomm sont les seuls NPU mobiles avec un jeu d'instructions ouvert, ce qui a attiré beaucoup plus de travaux d'optimisation pour le déploiement de LLM [arXiv](https://arxiv.org/html/2410.03613v3). Qualcomm propose le QNN SDK pour déployer directement sur le NPU, ce que MediaTek ne permet pas aussi facilement. + +De plus, le Snapdragon 8 Elite délivre 300% de performance NPU en plus par rapport au Snapdragon 8 Gen 3 [Abdelkareem Elkhateb](https://kareemai.com/blog/posts/products_reviews/one_plus_pad_3.html). Le rapport qualité/prix est imbattable pour ton projet. + +**Faisons le budget mémoire de ton pipeline complet sur le OnePlus Pad 3 (12 Go de RAM) :** + + Modèle Qwen 1.7B (INT4) ~1.0 Go + Whisper small (encoder partagé) ~0.5 Go + Classifieur émotion (DistilBERT) ~0.2 Go + Piper TTS ~0.1 Go + Avatar Unity (scène 3D) ~0.8 Go + KV Cache + buffers ~0.5 Go + OS Android + overhead ~3.0 Go + ───────────────────────────────────────── + TOTAL ~6.1 Go + Marge restante ~5.9 Go + +Tu as presque 6 Go de marge, c'est très confortable. Et si tu prends la version 16 Go, tu pourrais même envisager un modèle plus gros à terme (3B paramètres par exemple). + +**Pour le runtime d'inférence sur Android**, tu as plusieurs options qui fonctionnent bien avec le Snapdragon 8 Elite. llama.cpp compilé avec Vulkan pour le GPU ou en mode CPU optimisé ARM (avec les instructions i8mm) est le plus polyvalent. Le QNN SDK de Qualcomm permet de cibler directement le NPU Hexagon pour un maximum d'efficacité énergétique — essentiel pour une tablette en usage prolongé en milieu hospitalier. Et ONNX Runtime Mobile fonctionne bien pour les modèles plus petits comme ton classifieur d'émotion et Whisper. + +**Les avantages spécifiques pour le contexte hospitalier :** + +La batterie de 12 140 mAh [GSMArena](https://www.gsmarena.com/oneplus_pad_3_\(global\)-13881.php) est massive — ça représente facilement une journée complète d'utilisation en inférence intermittente. La charge 80W permet de recharger rapidement entre les sessions. Et à ~700$ la tablette contre ~1300$ minimum pour un iPad Pro, sur un déploiement de 10-20 unités, l'économie est considérable. + +**Le point d'attention principal** c'est la fragmentation logicielle qu'on mentionnait plus tôt. Android a plus de 5000 variantes de devices avec différentes architectures NPU, ce qui crée des cauchemars de test [F22 Labs](https://www.f22labs.com/blogs/what-is-on-device-ai-a-complete-guide/). Mais en ciblant **uniquement** le OnePlus Pad 3 (ou un modèle Snapdragon 8 Elite spécifique), tu élimines ce problème. Tu figes ton matériel cible et tu optimises exclusivement pour lui — exactement comme Apple le fait, mais sur Android. + +Tu veux qu'on détaille le pipeline technique complet pour déployer les modèles sur le Snapdragon 8 Elite, ou qu'on regarde d'abord les aspects MDM/déploiement en milieu hospitalier ? + +--- + +You asked: +---------- + +non je prendrai forcement la 16Go, vois tu un autre candidat ? + +Mar 11 + +--- + +Claude Replied: +--------------- + +Prospectait d'autres candidats tablettes avec 16GB RAM + +Prospectait d'autres candidats tablettes avec 16GB RAM + +Bonne idée les 16 Go, ça te donne de la marge pour faire évoluer les modèles. Laisse-moi chercher les autres candidats sérieux. + +Orchestrated device comparison criteria for medical deployment suitability + +Orchestrated device comparison criteria for medical deployment suitability + +Voici les candidats sérieux en 16 Go pour faire tourner Kazeia. Je les compare sur ce qui compte pour toi : puissance NPU, écosystème d'inférence ML, et viabilité en déploiement médical. + +**1\. OnePlus Pad 3 (16 Go / 512 Go) — ~750$** Reste mon premier choix. Snapdragon 8 Elite, 16 Go de RAM, 12 140 mAh [GSMArena](https://www.gsmarena.com/oneplus_pad_3_\(global\)-13881.php). Le NPU Hexagon à 75 TOPS est le mieux supporté par l'écosystème d'inférence Android (QNN SDK, llama.cpp optimisé). Seul bémol : pas de sécurité biométrique, juste un PIN [Tom's Guide](https://www.tomsguide.com/best-picks/best-android-tablets), et seulement 3 ans de mises à jour logicielles. Pour un déploiement médical, l'absence de biométrie est un peu gênant mais pas bloquant si les tablettes restent dans les chambres. + +**2\. Honor MagicPad 3 Pro — pas encore de prix confirmé** C'est un candidat très intéressant qui vient d'être annoncé : Snapdragon 8 Elite Gen 5, 12 ou 16 Go de RAM, batterie de 12 450 mAh avec charge 80W [GSMArena](https://www.gsmarena.com/honor_magicpad_3_pro-14243.php). L'avantage sur le OnePlus c'est qu'il a le même SoC Snapdragon 8 Elite (donc même écosystème NPU Hexagon) avec une batterie silicone-carbone qui offre une longévité exceptionnelle. Sur le MagicPad 3 standard, la batterie silicone-carbone dure remarquablement longtemps [SlashGear](https://www.slashgear.com/1972266/honor-magicpad-3-review-2025/). Le gros bémol : Honor ne propose qu'un seul upgrade Android et deux ans de patches de sécurité [TechRadar](https://www.techradar.com/tablets/honor-magic-pad-3-review), ce qui est catastrophique pour un usage médical. Et la disponibilité en France/Europe n'est pas garantie sur la version Pro. + +**3\. Samsung Galaxy Tab S11 Ultra (16 Go / 1 To) — ~1 619$** Dimensity 9400+ en 3 nm, jusqu'à 16 Go de RAM [GSMArena](https://www.gsmarena.com/samsung_galaxy_tab_s11_ultra_5g-14057.php). Gros avantages pour le contexte médical : certification IP68 eau et poussière [MobGadgets](https://mobgadgets.com/gadget/samsung-galaxy-tab-s11-ultra), et surtout 7 ans de mises à jour Android et patches de sécurité [Wikipedia](https://en.wikipedia.org/wiki/Samsung_Galaxy_Tab_S11). C'est énorme pour un déploiement hospitalier où tu veux que la tablette soit maintenue pendant des années. En revanche, le Dimensity 9400+ utilise le NPU MediaTek APU 790 à ~48 TOPS — puissant, mais l'écosystème d'inférence est moins mature que Qualcomm. Google vient de sortir LiteRT avec un accélérateur NeuroPilot natif pour MediaTek [Google Developers](https://developers.googleblog.com/mediatek-npu-and-litert-powering-the-next-generation-of-on-device-ai/), ce qui améliore les choses, mais c'est encore plus récent et moins éprouvé que le QNN SDK de Qualcomm. Autre problème : le 16 Go n'est disponible qu'avec la version 1 To [MobGadgets](https://mobgadgets.com/gadget/samsung-galaxy-tab-s11-ultra), ce qui fait exploser le prix. Et pour 1 619$ par tablette, sur 10-20 unités, c'est un budget considérable. + +**4\. RedMagic Astra — ~500-600$** Snapdragon 8 Elite avec jusqu'à 24 Go de RAM LPDDR5T [Android Central](https://www.androidcentral.com/best-android-gaming-tablets) — de loin la RAM la plus généreuse et la plus rapide. Le LPDDR5T offre une bande passante mémoire supérieure, ce qui est directement bénéfique pour l'inférence LLM (qui est memory-bandwidth bound). Le problème : l'écran ne fait que 9.06 pouces [GamesRadar+](https://www.gamesradar.com/best-gaming-tablet/). Pour un chatbot thérapeutique avec avatar 3D destiné à des patients, c'est trop petit. C'est vraiment conçu comme une tablette gaming compacte. + +**Mon classement pour Kazeia :** + +Le **OnePlus Pad 3** reste le meilleur compromis global. Snapdragon 8 Elite pour le NPU, 16 Go, bon prix, écran 13.2 pouces adapté à l'interaction patient/avatar. Le principal risque c'est les 3 ans de mises à jour seulement. + +Si la **longévité logicielle** est prioritaire pour la validation médicale (et elle devrait l'être), le **Samsung Galaxy Tab S11 Ultra** avec ses 7 ans de support est imbattable. Tu paies plus cher et tu perds en efficacité NPU, mais tu gagnes en pérennité, certification IP68, et crédibilité institutionnelle — un hôpital sera plus confiant avec du Samsung qu'avec du OnePlus. Et le support LiteRT/NeuroPilot de MediaTek progresse vite. + +Surveille aussi le **Honor MagicPad 3 Pro** quand les prix et la disponibilité seront confirmés — si Honor améliore sa politique de mises à jour, ça pourrait devenir le meilleur candidat technique. + +Tu veux qu'on creuse un de ces choix en particulier, ou qu'on passe à l'architecture logicielle pour déployer le pipeline sur l'une de ces tablettes ? + +--- + +You asked: +---------- + +très bien, par rapport aux actuels LLM utilisé dans Kazeia, nous continuerions à utiliser whisper pour le STT, mais nous pourrions utiliser QWEN3-TTS pour le clonage vocale, est ce que cela est bien compatible avec les performances de ces tablettes + +Mar 16 + +--- + +Claude Replied: +--------------- + +Investigated Qwen3-TTS compatibility with tablet hardware specifications + +Investigated Qwen3-TTS compatibility with tablet hardware specifications + +Bonne idée de regarder Qwen3-TTS, c'est tout récent. Laisse-moi vérifier ses specs et sa compatibilité avec ces tablettes. + +Évalué l'incompatibilité majeure de Qwen3-TTS avec les tablettes Android + +Évalué l'incompatibilité majeure de Qwen3-TTS avec les tablettes Android + +C'est une excellente idée sur le papier — Qwen3-TTS est impressionnant — mais il y a un problème majeur de compatibilité avec le déploiement sur tablette Android. Laisse-moi t'expliquer pourquoi. + +**Le problème fondamental : Qwen3-TTS est conçu pour des GPU CUDA, pas pour du mobile.** + +Le modèle 1.7B pèse 4.54 Go en mémoire, le 0.6B fait 2.52 Go [DEV Community](https://dev.to/gary_yan_86eb77d35e0070f5/qwen3-tts-complete-guide-to-open-source-text-to-speech-model-9oe). Côté matériel, la recommandation pour un usage en production est un RTX 3090 ou mieux, et le modèle 0.6B peut tourner sur des GPU plus anciens mais risque de ne pas atteindre le temps réel [DEV Community](https://dev.to/czmilo/qwen3-tts-the-complete-2026-guide-to-open-source-voice-cloning-and-ai-speech-generation-1in6). + +Concrètement, le modèle s'appuie sur vLLM pour le déploiement et l'inférence, et actuellement seul le mode offline est supporté [GitHub](https://github.com/QwenLM/Qwen3-TTS). Le code d'inférence officiel utilise PyTorch avec FlashAttention 2 en bfloat16, qui ne fonctionne qu'avec CUDA [Hugging Face](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base). Il n'existe **aucun export ONNX officiel, aucun support mobile, aucune version quantisée pour edge device**. D'ailleurs quelqu'un a déjà ouvert une issue sur sherpa-onnx demandant le support de Qwen3-TTS [GitHub](https://github.com/k2-fsa/sherpa-onnx/issues/3104), mais sans réponse pour l'instant. + +**Le budget mémoire exploserait.** Si tu empiles les deux modèles simultanément sur la tablette : + + Qwen chat LLM (~1.7B, INT4) ~1.0 Go + Qwen3-TTS 0.6B (FP16) ~2.5 Go ← problème + ou Qwen3-TTS 1.7B (FP16) ~4.5 Go ← gros problème + Whisper small ~0.5 Go + Classifieur émotion ~0.2 Go + Avatar Unity ~0.8 Go + OS + overhead ~3.0 Go + ───────────────────────────────────────────── + TOTAL (avec TTS 0.6B) ~8.0 Go + TOTAL (avec TTS 1.7B) ~10.0 Go + +Avec le modèle 0.6B, tu serais déjà à la limite sur 16 Go, et c'est en supposant qu'on arrive à le faire tourner — ce qui n'est pas acquis vu l'absence de support mobile. Même quantisé en INT4, le 0.6B ferait encore ~800 Mo, mais l'architecture multi-codebook avec MTP (Multi-Token Prediction) de Qwen3-TTS est complexe et n'a pas été testée sur des runtimes mobiles. + +**C'est d'autant plus problématique que Qwen3-TTS a le même souci qu'Auralis/XTTS v2** que tu as déjà identifié dans ton projet : la dépendance à vLLM et CUDA. Tu avais déjà conclu qu'Auralis était incompatible avec Android pour exactement ces raisons. Qwen3-TTS, bien qu'architecturalement différent (multi-codebook LM vs. GPT+DiT), a les mêmes contraintes de déploiement. + +**Cela dit, Qwen3-TTS a un potentiel énorme pour Kazeia — mais pas directement sur la tablette.** Voici ce que je recommanderais comme stratégie : + +Pour le **clonage vocal initial** (créer la voix personnalisée du chatbot, ou à terme la voix d'un proche du patient), utilise Qwen3-TTS sur un PC/serveur. Le modèle supporte le clonage zero-shot avec seulement 3 secondes d'audio de référence [GitHub](https://github.com/QwenLM/Qwen3-TTS) et couvre le français parmi ses 10 langues supportées [GitHub](https://github.com/QwenLM/Qwen3-TTS). Tu fais le clonage une fois, tu génères un profil vocal, et tu exportes ce que tu en as besoin. + +Pour la **synthèse en temps réel sur la tablette**, reste sur **Piper TTS**. C'est léger (~50-100 Mo), rapide, compatible Android, et tu peux fine-tuner des voix personnalisées. L'idée serait d'utiliser Qwen3-TTS pour créer des données d'entraînement de haute qualité (un corpus audio dans la voix clonée), puis d'entraîner un modèle Piper sur ces données. Tu obtiens le meilleur des deux mondes : la qualité de clonage de Qwen3-TTS et la légèreté de Piper pour le déploiement. + +Alternativement, surveille **sherpa-onnx** — c'est le projet qui a le plus de chances de porter Qwen3-TTS vers ONNX et donc vers Android. Si ça arrive dans les prochains mois, ça changerait la donne. Mais pour l'instant, ce n'est pas une option fiable pour planifier ton architecture. + +Tu veux qu'on explore cette stratégie hybride (Qwen3-TTS serveur + Piper tablette), ou qu'on regarde d'autres alternatives TTS compatibles Android qui offriraient un meilleur clonage vocal que Piper ? + +--- + +You asked: +---------- + +tu parles de sherpa-onnx, mais qu'est ce qu'il pourrait apporter au developpement sur tablette ? + +Mar 19 + +--- + +Claude Replied: +--------------- + +Investigated sherpa-onnx's potential benefits for tablet development + +Investigated sherpa-onnx's potential benefits for tablet development + +Très bonne question, sherpa-onnx pourrait en fait être un game-changer pour ton projet. Laisse-moi creuser ce qu'il propose exactement. + +Synthesized sherpa-onnx insights for tablet development applications + +Synthesized sherpa-onnx insights for tablet development applications + +Sherpa-onnx pourrait en fait devenir la **couche d'unification** de tout ton pipeline audio sur tablette. C'est bien plus qu'un simple wrapper ONNX. Voici ce qu'il apporterait concrètement à Kazeia. + +**Sherpa-onnx, c'est quoi exactement ?** C'est un framework développé par l'équipe next-gen Kaldi (k2-fsa) qui fournit du speech-to-text, text-to-speech, diarisation de locuteurs, amélioration de la parole, séparation de sources et VAD, le tout via ONNX Runtime et sans connexion Internet [GitHub](https://github.com/k2-fsa/sherpa-onnx). Le point clé : il supporte Android, iOS, HarmonyOS, Raspberry Pi, RISC-V, et même des NPU spécifiques comme les RK NPU et Ascend NPU [GitHub](https://github.com/k2-fsa/sherpa-onnx). En gros, c'est exactement le type de framework conçu pour le déploiement offline sur device embarqué — ton cas d'usage. + +**Ce que ça changerait pour Kazeia :** + +Premièrement, tu aurais un **runtime unifié** pour tout le pipeline audio. Actuellement dans ta documentation, tu utilises Whisper en Python pour le STT et Auralis/XTTS pour le TTS, deux systèmes séparés avec des dépendances différentes. Sherpa-onnx regroupe STT + TTS + VAD dans un seul framework, avec des APIs en C++, Java, Kotlin, C#, Swift, Dart, et même une intégration Unity existante [GitHub](https://github.com/k2-fsa/sherpa-onnx). L'intégration Unity est particulièrement intéressante pour toi puisque ton avatar 3D est développé en Unity. Il existe déjà un package Unity "PiperTTS" basé sur sherpa-onnx qui permet d'utiliser les voix Piper directement dans Unity sur Android [GitHub](https://github.com/DirtyBeastAfterTheToad/Piper-tts-unity-documentation). C'est littéralement ton cas d'usage : avatar Unity + TTS offline sur tablette Android. + +Deuxièmement, **les modèles TTS pour le français sont déjà disponibles**. Sherpa-onnx fournit des APK TTS prêts à l'emploi pour le français [GitHub](https://k2-fsa.github.io/sherpa/onnx/tts/apk-engine.html), basés sur les voix Piper. Ces APK sont classés par architecture (arm64-v8a pour les tablettes modernes), par langue, et par modèle. Tu n'aurais pas à faire la conversion toi-même — c'est pré-empaqueté. + +Troisièmement, côté **STT**, sherpa-onnx supporte Whisper mais aussi d'autres modèles potentiellement plus adaptés au mobile. Le changelog montre un support récent pour des modèles comme SenseVoice, FireRedASR, NeMo Canary, et des modèles Zipformer optimisés pour le streaming [Pub.dev](https://pub.dev/packages/sherpa_onnx/changelog). L'avantage des modèles Zipformer par rapport à Whisper, c'est qu'ils supportent le **streaming en temps réel** : le patient parle et la transcription apparaît au fur et à mesure, au lieu d'attendre la fin de la phrase. Pour un chatbot thérapeutique, cette réactivité est importante. + +Quatrièmement, et c'est crucial pour ton projet, sherpa-onnx inclut un **VAD (Voice Activity Detection)** intégré. Le framework supporte "ten-vad" avec des APIs C++, JavaScript et WebAssembly [Pub.dev](https://pub.dev/packages/sherpa_onnx/changelog). Le VAD détecte automatiquement quand le patient commence et arrête de parler, sans que tu aies à implémenter cette logique toi-même. Combiné au STT en streaming, ça donne une interaction vocale beaucoup plus naturelle. + +Cinquièmement, sherpa-onnx a récemment ajouté un support QNN (Qualcomm QNN SDK) [Pub.dev](https://pub.dev/packages/sherpa_onnx/changelog). C'est un détail technique mais c'est majeur pour toi : ça signifie que le framework peut potentiellement utiliser le **NPU Hexagon** du Snapdragon 8 Elite pour accélérer l'inférence des modèles audio. Au lieu de tout faire tourner sur le CPU (lent) ou le GPU (gourmand en batterie), les modèles STT et TTS pourraient être déchargés sur le NPU, qui est beaucoup plus efficace énergétiquement. + +**Ce que ça donnerait pour l'architecture Kazeia sur tablette :** + + ┌─────────────────────────────────────────────┐ + │ Application Unity │ + │ (Avatar 3D + Interface patient) │ + ├─────────────────────────────────────────────┤ + │ sherpa-onnx (C#/Java) │ + │ ┌──────┐ ┌──────┐ ┌──────┐ ┌────────┐ │ + │ │ VAD │→ │ STT │ │ TTS │ │Speaker │ │ + │ │ │ │Whisper│ │Piper │ │ ID │ │ + │ └──────┘ └──────┘ └──────┘ └────────┘ │ + ├─────────────────────────────────────────────┤ + │ ONNX Runtime Mobile │ + │ (CPU / GPU Vulkan / NPU via QNN) │ + ├─────────────────────────────────────────────┤ + │ llama.cpp │ Classifieur émotion │ + │ (Qwen LLM, INT4) │ (DistilCamemBERT) │ + ├─────────────────────────────────────────────┤ + │ Android / Snapdragon 8 Elite │ + └─────────────────────────────────────────────┘ + +L'avantage de cette architecture, c'est que sherpa-onnx gère toute la couche audio de manière cohérente, pendant que llama.cpp gère le LLM séparément. Les deux sont des projets C/C++ matures, optimisés pour ARM, et fonctionnent offline. Tu n'as plus Python nulle part dans la chaîne d'inférence sur la tablette — tout est natif. + +**Les limites à connaître.** Sherpa-onnx ne supporte pas encore Qwen3-TTS, comme on l'a vu. Il ne fait pas non plus de clonage vocal à proprement parler — les voix Piper sont pré-entraînées. Pour le clonage, tu restes sur la stratégie qu'on a discutée : cloner la voix sur PC avec Qwen3-TTS, puis entraîner un modèle Piper personnalisé qui sera déployé via sherpa-onnx sur la tablette. Et l'intégration Unity sur Android a été demandée par la communauté [GitHub](https://github.com/k2-fsa/sherpa-onnx/issues/796) et fonctionne, mais demande un peu de travail de configuration. + +En résumé, sherpa-onnx ne résout pas le problème du clonage vocal en temps réel sur tablette, mais il te fournit une base solide et éprouvée pour unifier STT + TTS + VAD dans un seul framework natif, optimisé pour Android offline, avec une intégration Unity qui existe déjà. C'est exactement la brique d'infrastructure qui manque à Kazeia pour passer du prototype Python/Flask sur PC à un produit déployable sur tablette. + +Tu veux qu'on détaille comment structurer le pipeline complet avec sherpa-onnx + llama.cpp + le classifieur d'émotion, ou qu'on explore la partie clonage vocal Piper à partir de données Qwen3-TTS ? + +--- + +You asked: +---------- + +est ce que tu crois qu'une implémentation from scratch pour implémenter nos modèles et pour matcher avec nos attentes (sur tablette) prendrait du temps ? + +Mar 19 + +--- + +Claude Replied: +--------------- + +Évalué la faisabilité technique du déploiement sur tablette + +Évalué la faisabilité technique du déploiement sur tablette + +C'est LA question pragmatique. Soyons honnêtes et réalistes — oui, ça prendrait du temps, mais pas autant qu'on pourrait le craindre si vous structurez bien les phases. Voici mon estimation détaillée. + +**Le point de départ : où vous en êtes aujourd'hui.** Vous avez un prototype fonctionnel en Python/Flask qui tourne sur PC avec Whisper, un Qwen fine-tuné, Auralis/XTTS, ChromaDB, et une interface web. Tout est en Python, tout dépend de CUDA, rien ne tourne sur mobile. C'est essentiellement une **réécriture complète du runtime d'inférence**, pas du code métier (la logique de conversation, le PHQ-9, la machine à états — tout ça reste). + +**Estimation par composant :** + +Pour la **couche STT via sherpa-onnx**, c'est le plus simple. Les modèles Whisper sont déjà convertis en ONNX et supportés nativement. L'intégration avec Unity via C# existe déjà dans des exemples. Comptez 1-2 semaines pour avoir le STT streaming qui fonctionne dans Unity sur Android avec le VAD. + +Pour la **couche TTS via sherpa-onnx + Piper**, c'est relativement rapide aussi puisque les voix françaises Piper existent et le package Unity PiperTTS est documenté. Le gros du travail serait de fine-tuner une voix Piper personnalisée pour Kazeia (sur PC, avec vos échantillons audio). L'intégration sur tablette elle-même prendrait 1-2 semaines, mais l'entraînement de la voix personnalisée ajouterait 2-3 semaines supplémentaires si vous voulez un résultat de qualité. + +Pour le **LLM Qwen via llama.cpp**, c'est le cœur du travail. Il faut convertir votre modèle fine-tuné au format GGUF, valider que la qualité des réponses ne se dégrade pas trop avec la quantization INT4, et intégrer llama.cpp dans l'application Android. La conversion et la validation prendraient 1-2 semaines. L'intégration native (JNI ou via un wrapper C#/Unity) ajouterait 2-3 semaines, surtout pour gérer correctement le streaming de tokens, le prompt templating, et le contexte conversationnel que vous gérez actuellement dans conversation\_manager.py. + +Pour le **classifieur d'émotion**, il faut fine-tuner un DistilCamemBERT sur des données d'émotion, le convertir en ONNX, et l'intégrer via ONNX Runtime Mobile. Le fine-tuning prendrait 2-3 semaines (collecte/préparation des données incluse). L'intégration sur tablette, 1 semaine — c'est un modèle léger avec une inférence simple. + +Pour le **portage de la logique métier** (machine à états, PHQ-9, gestion de profil, historique de conversation), c'est un travail de réécriture de Python vers Java/Kotlin ou C#. La logique n'est pas complexe — votre conversation\_manager.py fait 649 lignes, le phq9\_manager.py fait 95 lignes. Comptez 2-3 semaines pour tout porter proprement avec la base SQLite locale. + +Pour l'**avatar Unity**, vous l'avez déjà en cours de développement. L'intégration avec le pipeline audio (lip-sync depuis le TTS, expressions faciales depuis le classifieur d'émotion) ajouterait 2-3 semaines de travail. + +Pour le **serveur clinicien** (l'interface médecin qui consulte les données PHQ-9 et les historiques), c'est en fait le plus simple — ça peut rester en Python/Flask sur un PC classique à l'hôpital. La tablette synchronise ses données SQLite avec le serveur quand elle est connectée au réseau local. Comptez 1-2 semaines pour implémenter la sync. + +**Au total, en estimation réaliste :** + + Composant Optimiste Réaliste Pessimiste + ───────────────────────────────────────────────────────────────────── + STT (sherpa-onnx + Whisper) 1 sem 2 sem 3 sem + TTS (sherpa-onnx + Piper) 2 sem 3-4 sem 6 sem + LLM (llama.cpp + GGUF) 3 sem 4-5 sem 7 sem + Classifieur émotion 2 sem 3 sem 5 sem + Logique métier (portage) 2 sem 3 sem 4 sem + Avatar Unity (intégration audio) 2 sem 3 sem 4 sem + Serveur clinicien + sync 1 sem 2 sem 3 sem + Tests, debug, optimisation 2 sem 3-4 sem 6 sem + ───────────────────────────────────────────────────────────────────── + TOTAL ~15 sem ~22-24 sem ~38 sem + ~4 mois ~5-6 mois ~9 mois + +**Donc réalistement, entre 5 et 6 mois à deux développeurs.** C'est ambitieux mais faisable, surtout si vous parallélisez : un développeur sur le pipeline d'inférence (LLM + STT + TTS + émotion) pendant que l'autre travaille sur l'avatar Unity et l'intégration finale. + +**Ce qui pourrait raccourcir significativement le délai :** + +Ne pas tout faire d'un coup. Adopter une approche par phases : d'abord un MVP tablette avec juste le LLM (llama.cpp) + STT (sherpa-onnx/Whisper) + TTS (Piper voix française par défaut) + interface texte simple. Ça, c'est faisable en 6-8 semaines. Ensuite vous ajoutez l'avatar, le classifieur d'émotion, le clonage vocal, la synchronisation serveur dans des itérations suivantes. Le MVP vous permet de tester tôt sur la tablette cible et de valider que les performances sont acceptables avant d'investir dans les couches plus complexes. + +**Ce qui pourrait allonger significativement :** + +La partie la plus risquée, c'est la qualité du LLM après quantization. Si votre Qwen fine-tuné perd trop en qualité empathique en passant en INT4, vous pourriez devoir réentraîner ou changer de modèle de base, ce qui ajouterait facilement un mois. L'autre risque c'est l'optimisation des performances sur la tablette spécifique — la fragmentation Android est réelle, et même en ciblant un seul device, il peut y avoir des surprises (throttling thermique, gestion mémoire Android qui tue les processus en arrière-plan…). + +**Mon conseil :** commencez dès maintenant par convertir votre Qwen en GGUF et testez-le sur une tablette cible avec llama.cpp. C'est le test le plus critique et le plus rapide à faire. Si les performances et la qualité sont au rendez-vous, le reste suivra. Si ce n'est pas le cas, mieux vaut le savoir tout de suite plutôt qu'après 3 mois de développement. + +Tu veux qu'on prépare un plan de développement phasé plus détaillé, ou qu'on commence par la conversion du modèle Qwen en GGUF pour valider la faisabilité ? + +--- + +You asked: +---------- + +mais il n'y a pas déjà un système STT sous Android ? + +Mar 19 + +--- + +Claude Replied: +--------------- + +Examined Android's native speech recognition capabilities and offline constraints + +Examined Android's native speech recognition capabilities and offline constraints + +Très bonne remarque — oui, Android a un STT natif, et c'est une question importante à poser avant de réinventer la roue. Laisse-moi vérifier ce qu'il offre exactement et si ça suffirait pour Kazeia. + +Synthétisé les capacités STT natives d'Android pour Kazeia + +Synthétisé les capacités STT natives d'Android pour Kazeia + +Très bon réflexe — oui, Android a un STT natif, et ça pourrait te faire économiser pas mal de travail. Mais il y a des nuances importantes pour ton cas. + +**Ce qui existe nativement sur Android :** + +Android intègre `:antCitation[]{citations="a0863b32-9aff-4f2f-8b13-0f758ce057fc"}SpeechRecognizer`, qui est directement disponible sans bibliothèque supplémentaire et fonctionne même hors ligne. Depuis Android 12 (API 31) et Android 13 (API 33), Google a ajouté des fonctionnalités spécifiques pour la reconnaissance vocale on-device [Medium](https://medium.com/reveri-engineering/android-speech-to-text-the-missing-guide-part-1-824e2636c45a). Concrètement, tu peux appeler `SpeechRecognizer.createOnDeviceSpeechRecognizer()` et avoir du STT offline, en français, sans rien installer de plus. Ça tourne localement sur le device, assure la confidentialité et une faible latence, ne nécessite pas de connexion internet, est gratuit sans quota d'API, et est facile à intégrer [WebRTC.ventures](https://webrtc.ventures/2025/03/real-time-speech-transcription-on-android-with-speechrecognizer/). + +**Pour Kazeia, ça coche beaucoup de cases :** c'est offline, c'est gratuit, c'est natif Android, le français est supporté, et surtout c'est **zéro effort d'intégration** comparé à embarquer Whisper ou sherpa-onnx. Tu gagnerais les 1-2 semaines de travail d'intégration STT, et surtout tu n'as pas ~500 Mo de modèle Whisper à charger en mémoire. + +**Mais il y a des limites sérieuses pour un contexte thérapeutique :** + +La précision est moindre comparée aux solutions cloud, notamment pour les phrases complexes ou le vocabulaire spécialisé [WebRTC.ventures](https://webrtc.ventures/2025/03/real-time-speech-transcription-on-android-with-speechrecognizer/). Dans un contexte de soutien émotionnel, les patients peuvent parler avec hésitation, des pauses longues, des mots chargés émotionnellement, parfois en chuchotant ou en pleurant. Le SpeechRecognizer natif de Google n'est pas optimisé pour ça — il est conçu pour des commandes vocales et de la dictée standard. + +L'autre problème, c'est que tu perds complètement le contrôle sur le modèle. Tu ne peux pas le fine-tuner, tu ne peux pas extraire les features audio intermédiaires (prosodie, énergie vocale, pauses) pour ton classifieur d'émotion, et tu dépends des mises à jour Google pour la qualité. Rappelle-toi notre discussion sur le multi-tâche Whisper : l'idée était de partager l'encoder entre la transcription et la détection d'émotion. Avec le SpeechRecognizer natif, c'est une boîte noire — texte en entrée, texte en sortie, rien d'autre. + +Et il y a un point pratique souvent sous-estimé : le SpeechRecognizer a des comportements parfois erratiques [Medium](https://medium.com/reveri-engineering/android-speech-to-text-the-missing-guide-part-1-824e2636c45a) selon les fabricants et versions Android. Samsung, OnePlus, et Google n'implémentent pas tous le mode offline de la même manière. Sur certains devices, le mode offline requiert de télécharger manuellement le pack de langue via les paramètres Google, ce qui n'est pas idéal pour un déploiement hospitalier où tu veux que tout fonctionne out of the box. + +**Ma recommandation pragmatique : utilise-le pour le MVP, puis migre.** + +Pour ta première phase (le prototype tablette en 6-8 semaines), le SpeechRecognizer natif est parfait. Ça fonctionne, c'est rapide à intégrer, et ça te permet de tester le reste du pipeline (LLM + logique conversationnelle + avatar) sans perdre du temps sur le STT. Tu branches le SpeechRecognizer natif, tu récupères le texte, tu l'envoies au LLM via llama.cpp, et tu as un prototype fonctionnel. + +Dans une deuxième phase, quand le reste du pipeline est stable, tu remplaces le SpeechRecognizer par Whisper via sherpa-onnx. Ça te donne le contrôle sur les features audio pour la détection d'émotion, une meilleure gestion des situations vocales difficiles (pleurs, hésitations, chuchotements), et l'indépendance vis-à-vis de Google. + +Le même raisonnement s'applique d'ailleurs au **TTS natif Android** (`android.speech.tts.TextToSpeech`). Il existe, il fonctionne offline en français, mais la qualité est robotique et inadaptée à un contexte thérapeutique. Pour le MVP tu pourrais l'utiliser temporairement, mais Piper via sherpa-onnx sera indispensable pour la version finale. + +En résumé, le STT natif Android c'est un raccourci légitime pour démarrer, mais pas une solution finale pour un produit médical qui a besoin de fiabilité, de contrôle, et d'extraction de features émotionnelles. C'est la différence entre un prototype et un dispositif validé cliniquement. + +Tu veux qu'on structure ce plan de développement phasé avec les technologies à utiliser à chaque étape ? + +--- + +You asked: +---------- + +unity a des pilotes sous android ? et le fait de cloner un visage, de le modeliser, puis de l'animer st possible avec Unity ? + +Mar 19 + +--- + +Claude Replied: +--------------- + +Démêlé deux questions techniques sur Unity et l'animation faciale + +Démêlé deux questions techniques sur Unity et l'animation faciale + +Deux excellentes questions — laisse-moi vérifier l'état actuel du support Android dans Unity et les possibilités de clonage/animation faciale. + +Rassemblé les informations pour répondre aux deux questions + +Rassemblé les informations pour répondre aux deux questions + +Deux questions, deux bonnes nouvelles. + +**Unity sur Android : oui, c'est du support natif de première classe.** + +Unity inclut un support [Unity](https://docs.unity3d.com/6000.3/Documentation/Manual/android-introducing.html) natif pour la plateforme Android, ce qui signifie que tu peux utiliser Unity et ses outils pour créer et distribuer des applications 2D et 3D pour Android. Unity supporte Android 7.1 "Nougat" (API 25) et au-dessus [Unity](https://docs.unity3d.com/6000.3/Documentation/Manual/android-requirements-and-compatibility.html), donc toutes les tablettes qu'on a évoquées sont compatibles. Tu développes dans l'éditeur Unity sur PC, tu builds en un clic un APK ou AAB, et ça tourne directement sur la tablette. Pas de "pilotes" à installer — Unity gère le rendu via OpenGL ES ou Vulkan sur Android, et le build process compile tout en code natif ARM via IL2CPP. + +Unity 6.1 a même ajouté le support des grands écrans pliables Android et des tablettes [PocketGamer](https://www.pocketgamer.biz/unity-61-will-add-android-foldable-screen-support-and-other-new-platform-options/), avec des optimisations spécifiques pour ces form factors. Et des plugins comme SALSA LipSync sont testés et supportés sur Android [Crazyminnowstudio](https://crazyminnowstudio.com/unity-3d/lip-sync-salsa/features/). Donc tu n'es pas du tout en terrain inconnu — des milliers d'apps Unity tournent sur tablettes Android. + +**Pour le clonage de visage, la modélisation et l'animation : oui, c'est faisable, mais en deux étapes distinctes.** + +L'idée de prendre une photo d'un proche du patient et d'en faire un avatar animé qui parle est techniquement réalisable, mais il faut bien séparer la **création du modèle 3D** (étape lourde, sur PC) de l'**animation en temps réel** (étape légère, sur tablette). + +Pour la **création du modèle 3D à partir d'une photo**, ça ne se fait pas directement dans Unity sur la tablette. Tu utilises des outils externes sur PC comme DECA ou EMOCA (que tu avais déjà identifiés) pour reconstruire un mesh 3D du visage à partir d'une ou plusieurs photos. Il existe aussi des solutions plus accessibles comme Avatar SDK, qui génère des avatars 3D réalistes avec blendshapes depuis des photos et qui fournit un plugin Unity avec des blendshapes compatibles visèmes pour le lip-sync [Avatarsdk](https://docs.avatarsdk.com/unity-plugin/3.0.1/using_oculus_lipsync_with_avatar_sdk.html). Le workflow serait : photo du proche → génération du mesh 3D + textures + blendshapes sur PC → export en format compatible Unity (FBX) → déploiement sur la tablette. + +Pour l'**animation en temps réel sur la tablette**, c'est là que Unity brille. Plusieurs approches sont disponibles et éprouvées : + +Pour le lip-sync, uLipSync est un plugin Unity open-source qui analyse les formes audio en temps réel via MFCC et drive les blendshapes du visage [GitHub](https://github.com/hecomi/uLipSync). Il fonctionne par analyse de la forme d'onde audio — tu lui envoies la sortie de ton TTS (Piper), et il anime automatiquement la bouche du personnage. C'est léger, ça tourne sur mobile, et c'est indépendant de la langue puisque ça analyse le signal audio, pas le texte. Pour les avatars 3D dans Unity, les visèmes (formes de bouche) extraites de l'audio sont utilisées pour piloter les blendshapes du visage du personnage [DEV Community](https://dev.to/anhducmata/ai-powered-conversational-avatar-system-tools-best-practices-oe0). + +Pour les **expressions émotionnelles**, c'est là que ton classifieur d'émotion entre en jeu. Quand le classifieur détecte que le patient est triste, tu ajustes les blendshapes de l'avatar (sourcils baissés, regard doux, léger sourire compatissant). Des plugins comme SALSA LipSync Suite gèrent en parallèle le lip-sync, les émotions faciales (EmoteR), et les mouvements des yeux, le tout en temps réel sur Android [Crazyminnowstudio](https://crazyminnowstudio.com/unity-3d/lip-sync-salsa/features/). C'est exactement le type de comportement empathique que tu veux pour Kazeia. + +**Le pipeline complet pour les avatars personnalisés ressemblerait à ça :** + + PHASE 1 — Sur PC (une seule fois par patient/proche) + ───────────────────────────────────────────────────── + Photo(s) du proche + │ + ▼ + DECA/EMOCA ou Avatar SDK + │ + ▼ + Mesh 3D + Textures + Blendshapes (visèmes + émotions) + │ + ▼ + Export FBX → transfert vers tablette + + + PHASE 2 — Sur tablette (temps réel, à chaque session) + ────────────────────────────────────────────────────── + ┌─────────────────┐ + │ Avatar Unity │ + │ (mesh importé) │ + └────────┬────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ┌────────▼───┐ ┌──────▼─────┐ ┌────▼────────┐ + │ uLipSync │ │ Émotions │ │ Animations │ + │ (audio TTS │ │ (classif. │ │ corporelles│ + │ → bouche) │ │ → visage) │ │ (idle, │ + └────────────┘ └────────────┘ │ respiration)│ + └─────────────┘ + +La phase 1 est la plus complexe techniquement, mais elle ne se fait qu'une seule fois. La phase 2 est légère et tourne parfaitement sur une tablette Android avec un Snapdragon 8 Elite. Unity gère le rendu 3D, les blendshapes, et les animations squelettiques de manière très efficace sur ARM — c'est son cœur de métier. + +Pour le MVP, tu pourrais même commencer avec des **avatars pré-fabriqués** (un personnage cartoon pour les enfants, un semi-réaliste pour les ados, un réaliste pour les adultes — comme tu l'avais prévu) et n'ajouter le clonage de visage personnalisé que dans une phase ultérieure. Ça réduit significativement la complexité initiale. + +Tu veux qu'on détaille le workflow de création d'avatar avec DECA/Avatar SDK, ou qu'on se concentre sur l'architecture complète du MVP tablette avec les avatars pré-fabriqués ? + +--- + +You asked: +---------- + +SI nous utilisons une tablette je me disais qu'un utilisateur pourrait activer sa session en utilisant le lecteur d'empreinte digitale, mais n'avons nous pas une limite du nombre d'empreinte qui peuvent être utilisé dans android ? + +Mar 20 + +--- + +Claude Replied: +--------------- + +Réfléchissant aux limites du nombre d'empreintes digitales sur Android + +Réfléchissant aux limites du nombre d'empreintes digitales sur Android + +Très bonne réflexion pour l'ergonomie en milieu hospitalier. Laisse-moi vérifier les limites exactes. + +Identifié limitation matérielle d'Android pour authentification biométrique multiple + +Identifié limitation matérielle d'Android pour authentification biométrique multiple + +Tu as mis le doigt (sans jeu de mots) sur un vrai problème. La limite est sévère et **bloquante pour ton cas d'usage hospitalier**. + +**La réalité des limites :** les appareils Samsung sont limités à 3 empreintes [Samsung Community](https://r1.community.samsung.com/t5/galaxy-a/biometrics-fingerprints-limited-to-3/td-p/11086054). D'autres modèles vont jusqu'à 4 ou 5 empreintes maximum [Android Central](https://forums.androidcentral.com/threads/question-can-two-different-people-register-a-fingerprint-on-s22.1041555/). C'est une limitation matérielle liée à l'espace de stockage sécurisé du chip [Jetroid](https://jetholt.com/samsung-maximum-fingerprints/) — les templates biométriques sont stockées dans le TEE (Trusted Execution Environment) qui a une capacité très limitée. Les données biométriques brutes et les templates ne sont jamais accessibles en dehors de l'environnement isolé sécurisé (TEE ou Secure Element), et doivent être chiffrées avec une clé spécifique au device [Android Open Source Project](https://source.android.com/docs/security/features/biometric). + +En clair : **3 à 5 empreintes par tablette, point final.** Tu ne peux pas contourner ça de manière propre. Il existe des astuces comme enregistrer deux doigts différents dans un seul slot d'empreinte en alternant les doigts pendant l'enregistrement [Jetroid](https://jetholt.com/samsung-maximum-fingerprints/), mais ça dégrade la fiabilité de la reconnaissance et ce n'est absolument pas acceptable pour un dispositif médical. + +**Pourquoi c'est un problème pour Kazeia :** dans un service hospitalier, tu pourrais avoir 10, 20, voire 50 patients qui utilisent la même tablette à tour de rôle. 3-5 empreintes, c'est ridicule. Et même si chaque patient avait sa propre tablette, tu voudrais quand même que le personnel soignant puisse accéder à la tablette pour la maintenance ou la configuration. + +**Les alternatives pour l'authentification patient :** + +La plus pragmatique serait un **code PIN personnalisé par patient**, couplé au lecteur d'empreinte pour le personnel soignant. Le médecin ou l'infirmier enregistre son empreinte (1-2 slots) pour un accès rapide à l'interface clinicien, et chaque patient reçoit un code PIN à 4-6 chiffres pour sa session. C'est simple, ça fonctionne pour un nombre illimité de patients, et le PIN peut être généré automatiquement à l'admission. + +Une autre option intéressante serait un **badge NFC ou QR code patient**. Les tablettes Android supportent le NFC. Chaque patient reçoit un bracelet ou une carte NFC à l'admission (c'est déjà courant dans les hôpitaux). Il pose le bracelet sur la tablette, sa session se lance. C'est encore plus rapide qu'un PIN et ne nécessite aucune mémorisation — important pour des patients en détresse psychologique ou des enfants. Le OnePlus Pad 3 n'a malheureusement pas de NFC, mais le Samsung Galaxy Tab S11 Ultra en a. + +Tu pourrais aussi envisager la **reconnaissance faciale Android** comme alternative biométrique. Android supporte la reconnaissance faciale nativement, et contrairement aux empreintes, tu pourrais implémenter ton propre système de reconnaissance faciale au niveau applicatif (pas via le TEE Android) avec une base de données de visages patients gérée par ton app. Ce n'est pas aussi sécurisé que le TEE hardware, mais dans un contexte hospitalier contrôlé où la tablette reste dans l'établissement, c'est un compromis acceptable. Et ça réutiliserait potentiellement des composants de ton pipeline avatar (détection de visage, embeddings faciaux). + +**Ma recommandation pour Kazeia :** un système à deux niveaux. Les soignants s'authentifient par empreinte digitale (accès admin/clinicien, 2-3 empreintes enregistrées). Les patients s'authentifient par PIN ou badge NFC (accès session patient, nombre illimité). C'est à la fois sécurisé, scalable, et réaliste pour un déploiement hospitalier. + +Tu veux qu'on intègre ce système d'authentification dans l'architecture globale de Kazeia sur tablette ? + +--- + +You asked: +---------- + +N'est il pas possible d'utiliser le lecteur pour comparer avec des empreintes qui seraient stockés dans l'application kazeia ? + +Mar 20 + +--- + +Claude Replied: +--------------- + +Examined feasibility of bypassing system limits via direct sensor integration + +Examined feasibility of bypassing system limits via direct sensor integration + +Excellente idée — utiliser le capteur physique mais gérer la base d'empreintes côté application plutôt que via le TEE Android. Laisse-moi vérifier si c'est techniquement possible. + +Examined feasibility of custom fingerprint matching within app + +Examined feasibility of custom fingerprint matching within app + +Malheureusement non, et c'est verrouillé par design. C'est une bonne idée, mais Android rend ça volontairement impossible. + +**Le problème fondamental :** les données biométriques brutes et les templates d'empreintes ne doivent jamais être accessibles en dehors du pilote du capteur ou du TEE. Le canal SPI entre le capteur et le processeur sécurisé doit être accessible uniquement au TEE, protégé par une politique SELinux [Android Open Source Project](https://source.android.com/docs/security/features/authentication/fingerprint-hal). En clair, le capteur d'empreinte intégré à la tablette ne communique **qu'avec** l'environnement sécurisé du processeur. Ton application Kazeia, qui tourne dans l'espace utilisateur Android (le "Rich Execution Environment"), n'a physiquement aucun accès aux images ou templates d'empreintes. + +Les données d'empreinte doivent être sécurisées dans le hardware du capteur ou dans une mémoire de confiance, de sorte que les images d'empreintes ne soient pas accessibles. Seule la forme chiffrée peut être stockée sur le système de fichiers [Google Support](https://support.google.com/pixelphone/answer/6300638?hl=en). Et l'extraction des caractéristiques et la comparaison des templates sont effectuées dans l'environnement sécurisé isolé, les données brutes ne quittent jamais le hardware sécurisé et ne sont pas accessibles au système Android ni aux applications [Codez Up](https://codezup.com/android-fingerprint-authentication-guide/). + +Autrement dit, l'API `BiometricPrompt` te donne uniquement un résultat binaire : "oui c'est une empreinte enregistrée" ou "non". Tu ne reçois jamais l'image, ni le template, ni aucune donnée exploitable pour faire ton propre matching. + +**Il existe cependant une solution : un lecteur d'empreinte USB externe.** + +Pour capturer des images d'empreinte brutes ou des templates spécifiques au-delà de l'authentification OS standard, il faut interagir directement avec le hardware du capteur via des APIs spécifiques ou des SDK fabricants. Des scanners externes dédiés et leurs applications sont souvent utilisés pour gérer et stocker de multiples enregistrements d'empreintes [Sivo](https://hub.sivo.it.com/android-fingerprint-capture/how-to-capture-fingerprint-image-in-android/). + +Des fabricants comme SecuGen, DigitalPersona, ou Futronic proposent des lecteurs d'empreinte USB compacts (format clé USB ou petit boîtier) avec des SDK Android qui te donnent accès aux images brutes et aux templates. Avec un tel capteur externe, tu pourrais implémenter exactement ce que tu décris : enregistrer les empreintes de chaque patient dans la base SQLite de Kazeia (sous forme de template chiffré), et faire le matching côté application avec un nombre illimité d'empreintes. + +Le workflow serait : le patient pose son doigt sur le lecteur USB → le SDK du capteur te renvoie un template → tu le compares avec les templates stockés dans ta base de données chiffrée → tu identifies le patient et charges sa session. C'est exactement ce que font les systèmes de pointage ou d'identification dans les hôpitaux, les banques, etc. + +**Mais est-ce que ça vaut le coup pour Kazeia ?** Honnêtement, je ne suis pas sûr. Ça ajoute un périphérique externe (un câble USB-C qui dépasse de la tablette, pas idéal pour un objet posé au chevet du patient), un SDK supplémentaire à maintenir, et un coût par tablette (~50-100€ pour un lecteur de qualité). Et surtout, ça complexifie le déploiement — chaque tablette doit être équipée d'un lecteur, il faut gérer la compatibilité USB-OTG, etc. + +**Pour le contexte hospitalier, les alternatives restent plus pragmatiques :** + +Un **code PIN patient** à 4 chiffres reste la solution la plus simple et la plus fiable. Le patient le reçoit à l'admission, l'infirmier peut le réinitialiser si besoin, et ça fonctionne sur n'importe quelle tablette sans matériel supplémentaire. + +Un **badge NFC** (bracelet patient) serait encore plus élégant — le patient n'a rien à mémoriser, il pose son bracelet sur la tablette. Mais ça nécessite une tablette avec NFC (le Samsung Galaxy Tab S11 Ultra en a, pas le OnePlus Pad 3). + +Et le **lecteur d'empreinte intégré** reste utilisable pour le personnel soignant (2-3 empreintes pour les médecins/infirmiers qui accèdent à l'interface clinicien fréquemment). + +C'est un système à deux niveaux qui est le plus adapté au contexte réel d'un service hospitalier. Tu veux qu'on finalise l'architecture d'authentification complète ou qu'on passe à un autre aspect du projet ? + +--- + +You asked: +---------- + +Alors on oublie.... dommage ça aurait pu être intéressant + +Mar 20 + +--- + +Claude Replied: +--------------- + +Acknowledged disappointment tout en explorant alternatives futures + +Acknowledged disappointment tout en explorant alternatives futures + +Oui c'est frustrant, l'idée était vraiment bonne — une identification biométrique instantanée par le patient, sans rien mémoriser, c'est exactement le type d'interaction fluide qu'on veut dans un contexte thérapeutique. Un patient en détresse ne devrait pas avoir à se rappeler d'un code. + +Garde quand même ça dans un coin de ta roadmap. Le jour où Kazeia est validé cliniquement et que le déploiement s'industrialise, un petit lecteur USB-C SecuGen à 60€ par tablette pourrait redevenir pertinent — le surcoût serait négligeable à l'échelle d'un service hospitalier équipé. Et côté implémentation, les SDK de ces lecteurs (SecuGen, DigitalPersona) sont bien documentés avec des exemples Android/Java prêts à l'emploi. Ce serait quelques jours de travail, pas plus. + +En attendant, le système PIN patient + empreinte soignant est solide et suffisant pour la phase de validation. Et si vous choisissez une tablette avec NFC (comme le Samsung Tab S11 Ultra), le bracelet NFC patient reste une option très élégante qui pourrait même plaire aux équipes médicales — c'est un geste naturel qu'ils connaissent déjà avec les bracelets d'identification hospitaliers. + +Tu veux qu'on continue sur un autre aspect du projet, ou qu'on fasse un point récapitulatif de toutes les décisions architecturales qu'on a prises dans cette conversation ? + +--- + +You asked: +---------- + +comme nous avons une DB Chroma, pourrait on avoir ceci sous Android (ou un équivalent) + +Mar 22 + +--- + +Claude Replied: +--------------- + +Évalué la compatibilité de ChromaDB avec Android et alternatives + +Évalué la compatibilité de ChromaDB avec Android et alternatives + +Bonne question — le RAG est un composant important de Kazeia. Laisse-moi vérifier si ChromaDB tourne sur Android et quelles sont les alternatives. + +Évalué la compatibilité et exploré des alternatives mobiles viables + +Évalué la compatibilité et exploré des alternatives mobiles viables + +**ChromaDB directement sur Android : non**, c'est un service Python qui dépend de bibliothèques lourdes et n'est pas conçu pour le mobile. Mais il y a des alternatives excellentes et même mieux adaptées à ton cas. + +**La meilleure option pour Kazeia : sqlite-vec ou vectorlite, des extensions de recherche vectorielle pour SQLite.** + +C'est la solution la plus élégante parce que **tu utilises déjà SQLite** dans Kazeia pour tout le reste (utilisateurs, conversations, PHQ-9). sqlite-vec est une extension SQLite puissante qui permet de stocker, manipuler et interroger des données vectorielles directement dans SQLite, avec une recherche K-Nearest Neighbor, plusieurs métriques de distance, et des performances accélérées par SIMD, le tout dans un package portable sans dépendances [Medium](https://medium.com/@stephenc211/how-sqlite-vec-works-for-storing-and-querying-vector-embeddings-165adeeeceea). + +Concrètement, au lieu d'avoir deux bases de données séparées (SQLite pour les données relationnelles + ChromaDB pour les vecteurs), tu unifies tout dans une seule base SQLite. Tes documents de santé, tes fiches patients, et leurs embeddings vectoriels cohabitent dans le même fichier `.db`. C'est plus simple à déployer, à sauvegarder, et à synchroniser avec le serveur clinicien. + +L'auteur de sqlite-vec le conçoit spécifiquement pour tourner sur tous les systèmes d'exploitation, en WASM, sur les devices mobiles, et même sur des Raspberry Pi [Hacker News](https://news.ycombinator.com/item?id=40243168). C'est une bibliothèque C pure, compilable pour ARM64 (Android), sans dépendance externe. + +L'alternative **vectorlite** est encore plus performante pour ton cas : vectorlite utilise hnswlib, qui est optimisé pour la construction incrémentale d'index et les requêtes single-vector, ce qui fonctionne bien avec l'API des tables virtuelles SQLite [DEV Community](https://dev.to/yefuwang/introducing-vectorlite-a-fast-and-tunable-vector-search-extension-for-sqlite-4dcl). En benchmarks, vectorlite est 3x à 100x plus rapide que sqlite-vec pour les requêtes vectorielles, au prix d'un taux de rappel légèrement inférieur [GitHub](https://github.com/1yefuwang1/vectorlite). Pour ton usage (quelques centaines de documents de santé, pas des millions), les deux sont largement suffisants. + +**Il existe aussi ObjectBox**, une solution spécifiquement conçue pour le mobile : c'est une base de données vectorielle on-device pour mobile, IoT et systèmes embarqués, cloud-optional [ObjectBox](https://objectbox.io/vector-database-for-ondevice-ai/). ObjectBox a des SDK natifs Java/Kotlin pour Android et est optimisé pour les contraintes mobiles (faible consommation mémoire, performances sur ARM). C'est peut-être l'option la plus "clé en main" pour Android. + +**Pour le modèle d'embedding côté tablette**, tu ne pourras pas utiliser `sentence-camembert-base` tel quel (c'est un modèle Python/PyTorch). Mais tu peux le convertir en ONNX et l'exécuter via ONNX Runtime Mobile sur Android. Le modèle fait environ 100-200 Mo, ce qui est gérable. Alternativement, tu peux pré-calculer tous les embeddings des documents de santé sur le serveur (une seule fois) et ne déployer que les vecteurs pré-calculés sur la tablette. Le seul embedding à calculer en temps réel sur la tablette serait celui de la requête du patient — un seul vecteur à chaque tour de conversation. + +**Le pipeline RAG sur tablette deviendrait :** + + Message patient + │ + ▼ + Embedding model (ONNX, ~150 Mo) + │ + ▼ + Vecteur requête (768 dimensions) + │ + ▼ + SQLite + sqlite-vec / vectorlite + (recherche KNN dans les documents pré-indexés) + │ + ▼ + Top-K documents pertinents + │ + ▼ + Injection dans le prompt LLM (llama.cpp) + +Tu n'as même plus besoin du cross-encoder de re-ranking (`crossencoder-camembert-base`) pour le MVP — avec un petit corpus de documents de santé (ta base ChromaDB actuelle fait ~35 Mo), une recherche KNN directe avec un bon modèle d'embedding sera suffisante. Le cross-encoder, tu pourrais le réintroduire plus tard si tu augmentes significativement le corpus. + +**Le budget mémoire mis à jour avec cette approche :** + + Composant Mémoire + ───────────────────────────────────────────── + Qwen LLM (INT4) ~1.0 Go + Whisper small (STT) ~0.5 Go + Embedding model (ONNX) ~0.2 Go + Classifieur émotion ~0.2 Go + Piper TTS ~0.1 Go + SQLite + vecteurs ~0.05 Go + Avatar Unity ~0.8 Go + OS Android ~3.0 Go + ───────────────────────────────────────────── + TOTAL ~5.85 Go + Marge sur 16 Go ~10.15 Go + +Plus de 10 Go de marge — tu as largement la place. Et c'est une architecture beaucoup plus propre que l'originale : une seule base de données SQLite pour tout, pas de ChromaDB séparé à maintenir. + +Tu veux qu'on fasse un récapitulatif complet de l'architecture tablette avec toutes les décisions qu'on a prises dans cette conversation ? + +--- + +You asked: +---------- + +Maintenant nous allons partir sur un developpement purement android, comment dois je faire ? nous allons avoir une tablette, mais faut il un environnement android (virtuel) + +Mar 22 + +--- + +Claude Replied: +--------------- + +Évalué les options de développement Android et environnement virtuel + +Évalué les options de développement Android et environnement virtuel + +Excellente question pratique — c'est le moment de passer au concret. Laisse-moi vérifier les meilleures pratiques actuelles pour le développement Android natif avec Unity et les composants natifs. + +Rassemblé les informations pour clarifier le développement Android + +Rassemblé les informations pour clarifier le développement Android + +Il y a un point crucial à savoir d'entrée : Unity ne supporte pas les émulateurs Android. Pour tester une application Unity, il [Unity](https://docs.unity3d.com/6000.3/Documentation/Manual/android-requirements-and-compatibility.html) faut soit tester sur un appareil Android physique, soit utiliser Unity Remote (qui streame l'affichage depuis l'éditeur vers le téléphone), soit utiliser le Device Simulator intégré à Unity pour vérifier l'apparence. + +C'est un point décisif pour Kazeia. Puisque ton application est centrée sur Unity (avatar 3D, lip-sync, rendu temps réel) + des composants natifs C/C++ (llama.cpp, sherpa-onnx), **tu auras besoin d'une tablette physique assez tôt dans le développement**. Pas moyen d'y couper. + +**Concrètement, voici l'environnement de développement dont vous avez besoin :** + +**Sur ton PC de développement**, tu as besoin de deux outils principaux. D'abord **Unity Editor** (Unity 6 ou supérieur) avec le module Android Build Support installé. C'est là que tu développes l'avatar, l'interface patient, le lip-sync, et que tu intègres les plugins natifs (sherpa-onnx, llama.cpp). Unity gère la compilation vers Android (APK/AAB) en interne — il embarque le SDK Android, le NDK et Gradle automatiquement. Ensuite **Android Studio**, pas forcément pour coder dedans, mais parce qu'il fournit l'Android SDK, les outils ADB (Android Debug Bridge) pour communiquer avec la tablette, et le Logcat pour lire les logs en temps réel. Tu en auras aussi besoin si tu développes des composants natifs Java/Kotlin en dehors de Unity (par exemple le service d'authentification, la synchronisation avec le serveur clinicien, ou l'intégration du SpeechRecognizer natif). + +**Pour la tablette physique**, le workflow quotidien serait le suivant. Tu branches la tablette en USB-C sur ton PC (ou en Wi-Fi via ADB wireless depuis Android 11). Tu actives le mode développeur sur la tablette (en tapant 7 fois sur le numéro de build dans les paramètres) puis tu actives le débogage USB. Tu peux ensuite déployer et déboguer ton application directement depuis Android Studio ou Unity, par câble USB ou par Wi-Fi [Android Developers](https://developer.android.com/studio/run/device). Depuis Unity, tu cliques sur "Build and Run" et l'APK s'installe et se lance directement sur la tablette connectée. C'est un cycle de quelques minutes entre une modification et le test. + +**Faut-il acheter la tablette cible (OnePlus Pad 3 ou Samsung Tab S11 Ultra) dès le début ?** Pas nécessairement. Pour les premières semaines de développement (interface, logique conversationnelle, intégration des composants), n'importe quelle tablette ou téléphone Android récent suffit. Le LLM via llama.cpp, le STT natif, le TTS Piper — tout ça fonctionnera sur un appareil Android standard pour le développement. Par contre, dès que tu voudras valider les **performances réelles** (latence du LLM, fluidité de l'avatar, budget mémoire complet), il te faudra la tablette cible. Mon conseil : commande-la dans les 2-3 premières semaines pour l'avoir sous la main quand les premiers composants seront prêts à être benchmarkés. + +**L'émulateur Android Studio reste utile pour certaines choses**, même si Unity ne le supporte pas directement. Si tu développes des composants purement Java/Kotlin en dehors de Unity (un service Android, une activité de configuration, le module d'authentification), tu peux les prototyper et tester dans l'émulateur. L'émulateur offre flexibilité, haute fidélité et rapidité — il peut simuler différents appareils et niveaux d'API Android sans avoir besoin de chaque appareil physique [Android Developers](https://developer.android.com/studio/run/emulator). Mais dès que tu intègres ces composants dans l'app Unity, retour obligatoire au device physique. + +**Le setup recommandé pour toi et Damien :** + + PC Développeur 1 (ex: Richard) + ├── Unity Editor 6.x + Android Build Support + ├── Android Studio (SDK, ADB, Logcat) + ├── IDE C# (Rider ou VS Code avec extensions Unity) + ├── Tablette Android (dev/test) branchée en USB-C + └── Serveur local pour : + ├── Entraînement des modèles (Qwen, émotion, Piper) + ├── Conversion GGUF / ONNX + └── Clonage vocal Qwen3-TTS + + PC Développeur 2 (ex: Damien) + ├── Unity Editor 6.x + Android Build Support + ├── Android Studio + ├── Même tablette ou une seconde pour tests parallèles + └── Git (repo partagé) + +**Pour le versioning**, utilisez Git avec Git LFS pour les fichiers lourds (modèles ONNX, GGUF, assets Unity). Les modèles eux-mêmes ne devraient pas être dans le repo principal — stockez-les séparément (un NAS, un drive partagé) et référencez-les dans le build. + +**Un dernier point pratique :** le cycle de build Unity vers Android est plus lent que le développement natif pur (comptez 1-3 minutes par build selon la taille du projet). Pour accélérer l'itération, développe et teste un maximum dans l'éditeur Unity sur PC (la logique, l'UI, les animations), et ne déploie sur la tablette que quand tu as besoin de valider quelque chose de spécifique au mobile (performances, capteurs, audio réel). + +Tu veux qu'on détaille le setup technique d'un des composants spécifiques (par exemple comment intégrer llama.cpp dans un projet Unity Android), ou qu'on fasse le récapitulatif global de toute l'architecture qu'on a définie ? + +--- + +You asked: +---------- + +avant de passer sur la partie unity, je veux déjà pouvoir valider le fonctionnement du coeur de Kazeia, quel language utilisé pour optimiser les ressources ? + +Mar 22 + +--- + +Claude Replied: +--------------- + +Évalué les langages pour optimiser ressources Android avant intégration Unity + +Évalué les langages pour optimiser ressources Android avant intégration Unity + +Très bonne approche — valider le cœur avant de s'occuper du visuel. Pour un pipeline d'inférence offline sur tablette Android, la réponse est assez claire. + +**Le cœur de Kazeia est du C/C++ encapsulé, piloté par du Kotlin.** + +Voici pourquoi : regarde les composants que tu dois faire tourner. llama.cpp pour le LLM — c'est du C/C++. sherpa-onnx pour le STT et le TTS — c'est du C/C++. ONNX Runtime pour le classifieur d'émotion et le modèle d'embedding — c'est du C/C++. sqlite-vec pour la recherche vectorielle — c'est du C. Tous tes composants d'inférence critiques sont déjà écrits en C/C++ et optimisés pour ARM. Tu ne vas pas les réécrire, tu vas les **appeler**. + +La question devient donc : quel langage pour la couche applicative qui orchestre tout ça ? + +**Kotlin** est le bon choix pour cette couche. C'est le langage officiel et recommandé par Google pour Android, il a un accès direct au NDK (Native Development Kit) via JNI pour appeler du code C/C++, et il te permet de gérer tout ce qui est spécifique à Android : le cycle de vie de l'application, le SpeechRecognizer natif, SQLite, l'interface utilisateur, les permissions, la gestion mémoire. Kotlin est aussi bien plus concis et sûr que Java, ce qui compte quand vous n'êtes que deux développeurs. + +**L'architecture en couches ressemblerait à ça :** + + ┌─────────────────────────────────────────┐ + │ Couche Application (Kotlin) │ + │ │ + │ • Machine à états conversationnelle │ + │ • Gestion PHQ-9 │ + │ • Gestion des sessions/profils │ + │ • Orchestration du pipeline │ + │ • Interface de test (texte simple) │ + │ • Synchronisation serveur clinicien │ + ├─────────────────────────────────────────┤ + │ Couche Binding (JNI / Java API) │ + │ │ + │ • LlamaAndroid (wrapper llama.cpp) │ + │ • SherpaOnnx (wrapper Java fourni) │ + │ • ONNX Runtime Android (SDK officiel) │ + │ • SQLite + sqlite-vec │ + ├─────────────────────────────────────────┤ + │ Couche Inférence (C/C++ natif) │ + │ │ + │ • llama.cpp → LLM Qwen (GGUF) │ + │ • sherpa-onnx → STT Whisper + TTS │ + │ • onnxruntime → Émotion + Embeddings │ + │ • sqlite-vec → Recherche vectorielle │ + └─────────────────────────────────────────┘ + +**Le point important** c'est que tu n'écris presque pas de C/C++ toi-même. Ces bibliothèques fournissent déjà des bindings utilisables depuis Android. llama.cpp a un exemple Android officiel avec un wrapper Java/Kotlin. sherpa-onnx fournit des APIs Java et Kotlin prêtes à l'emploi, c'est même un de leurs points forts. ONNX Runtime a un SDK Android officiel (`com.microsoft.onnxruntime:onnxruntime-android`). Tu intègres ces dépendances dans ton projet, et tu les appelles depuis Kotlin comme des bibliothèques normales. + +**Pour la phase de validation du cœur**, concrètement, voici ce que je te recommande : + +Crée un projet Android Studio simple en Kotlin, sans Unity, avec une interface minimaliste : un champ texte pour taper un message, un bouton pour envoyer, et une zone d'affichage pour la réponse. Pas de graphismes, pas d'avatar, juste du texte. Ça te permet de valider que chaque composant fonctionne sur la tablette, de mesurer les latences réelles de chaque étape, et d'identifier les problèmes de mémoire ou de performance avant d'ajouter la complexité Unity. + +Le plan de validation serait séquentiel. D'abord intègre llama.cpp seul et vérifie que ton Qwen en GGUF génère des réponses correctes avec une latence acceptable. Ensuite ajoute le SpeechRecognizer natif Android pour le STT — c'est rapide à intégrer pour le MVP. Puis ajoute Piper TTS via sherpa-onnx pour entendre les réponses. Ensuite branche SQLite + sqlite-vec pour le RAG. Et enfin ajoute le classifieur d'émotion via ONNX Runtime. À chaque étape, tu mesures la mémoire utilisée et la latence. Si un composant pose problème, tu le sais immédiatement et tu peux ajuster avant que tout soit imbriqué. + +**Pourquoi pas Python sur Android ?** On pourrait être tenté d'utiliser Chaquopy ou d'autres solutions pour faire tourner du Python sur Android et réutiliser une partie du code existant de Kazeia. C'est une mauvaise idée pour plusieurs raisons : l'overhead mémoire du runtime Python est énorme sur mobile (200-400 Mo juste pour l'interpréteur + les dépendances), les performances sont nettement inférieures à du natif, et surtout les bibliothèques critiques (PyTorch, transformers, vLLM) ne tournent pas sur Android. Le code Python existant de Kazeia (conversation\_manager.py, phq9\_manager.py, etc.) devra être porté en Kotlin, mais ce sont des fichiers relativement courts — la logique métier, pas de l'inférence. + +**Pourquoi pas du C/C++ pur pour tout ?** Tu pourrais maximiser les performances en écrivant toute l'application en C++ avec le NDK. Mais tu perdrais l'accès facile aux APIs Android (SpeechRecognizer, gestion des permissions, SQLite natif, interface utilisateur), et le développement serait beaucoup plus lent. Le C/C++ doit rester cantonné à ce pour quoi il excelle : l'inférence de modèles. Le reste, Kotlin le fait mieux et plus vite. + +Tu veux qu'on commence par le setup du projet Android Studio avec l'intégration de llama.cpp, ou qu'on prépare d'abord la conversion de ton modèle Qwen en GGUF pour avoir quelque chose à tester ? + +--- + +You asked: +---------- + +Je veux que tu fasses une recherche pour valider quel est le meilleur soft pour faire tourner des LLM sur android (cela adaptera nos choix par la suite), dans le cas présent nous utiliserons un processeur snapdragon 8 Elite + +Mar 22 + +--- + +Claude Replied: +--------------- + +Parfait, c'est exactement le type de recherche qu'il faut faire avant de figer l'architecture. Juste quelques précisions pour que la recherche soit la plus ciblée possible : + +1. **Taille des modèles ciblés** : on se concentre uniquement sur des modèles ~1.7B (comme ton Qwen) ou tu veux aussi évaluer la faisabilité de modèles plus gros (3B, 7B) pour anticiper une évolution future ? +2. **Priorité principale** : tu privilégies la vitesse de génération (tokens/seconde), l'efficacité mémoire, ou le support du NPU Hexagon du Snapdragon 8 Elite spécifiquement ? +3. **Format de modèle** : tu veux comparer uniquement les runtimes pour des modèles GGUF, ou aussi d'autres formats comme ONNX et les modèles optimisés QNN (Qualcomm) ? + +Thought for 1s + +Thought for 1s + +Je lance la recherche dès que tu as choisi — ça me permettra de cibler exactement ce qui est pertinent pour votre cas. + +--- + +You asked: +---------- + +Q: Taille des modèles à évaluer ? A: 1.7B à 7B (vision large) + +Q: Priorité d'évaluation ? A: Support NPU Hexagon + +Q: Formats de modèle à comparer ? A: Tous les formats disponibles + +Mar 22 + +--- + +Claude Replied: +--------------- + +Parfait, les critères sont bien définis — modèles de 1.7B à 7B, priorité au NPU Hexagon, tous formats confondus. C'est une recherche pointue, je lance ça immédiatement. + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +LLM inference on Snapdragon Elite + +![](https://www.google.com/s2/favicons?domain=github.com&sz=32) + +![](https://www.google.com/s2/favicons?domain=arxiv.org&sz=32) + +![](https://www.google.com/s2/favicons?domain=qualcomm.com&sz=32) + +Research complete• + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +446 sources + +• + +7m 57s + +Your report on running LLMs on the Snapdragon 8 Elite's Hexagon NPU is ready. It covers all eight candidate frameworks, with ExecuTorch (QNN delegate) and Qualcomm's Genie SDK emerging as the top production paths for deploying a fine-tuned Qwen 1.7B. The report includes verified decode benchmarks, NPU quantization constraints and mitigations, memory/power profiling for 1.7B through 7B model sizes, Android integration details, and a look at the SM8850 and LiteRT-LM developments coming through 2026. + + # Install AI Hub Models package + pip install "qai-hub-models[qwen2-7b-instruct]" + + # Export for Snapdragon 8 Elite — compiles to QNN context binaries + python -m qai_hub_models.models.qwen2_7b_instruct.export \ + --chipset qualcomm-snapdragon-8-elite \ + --skip-profiling --output-dir genie_bundle + +Running LLMs on Snapdragon 8 Elite Hexagon NPU: Framework Analysis and Deployment Guide for Qwen Models + +Document  + +\# Running LLMs on Snapdragon 8 Elite's Hexagon NPU \*\*ExecuTorch with the QNN delegate and Qualcomm's own Genie SDK are the two strongest production paths for deploying a fine-tuned Qwen 1.7B on Android with Snapdragon 8 Elite's Hexagon NPU.\*\* ExecuT + +--- + +You asked: +---------- + +Donc quel est le runtime à prendre ? + +Mar 22 + +--- + +Claude Replied: +--------------- + +Synthesized research findings to recommend optimal runtime + +Synthesized research findings to recommend optimal runtime + +Basé sur toute la recherche, la réponse est claire : + +**ExecuTorch avec le delegate QNN** est le runtime à prendre pour Kazeia. Et voici pourquoi c'est le choix évident pour ton cas spécifique. + +Ton modèle Qwen est fine-tuné en PyTorch — ExecuTorch est littéralement le runtime edge de PyTorch. Tu exportes ton modèle directement sans passer par une conversion intermédiaire complexe. Le pipeline est : PyTorch → export .pte avec delegate QNN → déploiement sur tablette. ExecuTorch v1.0+ supporte explicitement Qwen parmi ses architectures de modèles, donc tu n'es pas en train de forcer un format non supporté. + +Pour le NPU Hexagon, c'est le meilleur compromis entre performance et flexibilité. Le delegate QNN partitionne automatiquement ton modèle : les opérations compatibles tournent sur le NPU, le reste retombe sur le CPU. Tu n'as pas à gérer ça manuellement. Et les gains sont réels — sur le Snapdragon 8 Elite, le NPU maintient des performances stables pendant plus de 10 minutes à 36-38°C là où le CPU throttle après 3 minutes à 42°C. Pour un chatbot thérapeutique où les sessions durent potentiellement 20-30 minutes, cette stabilité thermique est critique. + +Pour ton Qwen 1.7B en INT4, tu peux t'attendre à environ 30-50 tokens/seconde sur le NPU, avec seulement ~1.3 Go de RAM consommée. C'est largement suffisant pour une conversation fluide et ça laisse énormément de marge sur 16 Go pour le reste du pipeline (STT, TTS, avatar). Et si tu veux évoluer vers un modèle 3B ou 7B plus tard, ExecuTorch supporte le sharding de modèle — ton architecture ne change pas. + +Côté intégration Android, ExecuTorch fournit des packages Maven et une bibliothèque AAR avec une API Java, ce qui s'intègre proprement dans une app Kotlin. L'overhead sur la taille de l'APK est de seulement 5-15 Mo. + +**Garde llama.cpp en backup** pour deux raisons : le prototypage rapide (charger un GGUF et tester en 5 minutes, sans pipeline de conversion) et comme fallback CPU/GPU si tu rencontres des problèmes avec la quantization QNN sur ton modèle fine-tuné spécifique. La quantization pour le NPU peut parfois dégrader la qualité des réponses — c'est le risque principal mentionné dans le rapport. Si ça arrive, llama.cpp avec Vulkan (GPU Adreno 830) te donne un plan B solide pendant que tu ajustes la quantization. + +**En résumé, la stack d'inférence LLM pour Kazeia :** + + Production : ExecuTorch + QNN delegate (NPU Hexagon) + Fallback : llama.cpp + Vulkan (GPU Adreno 830) + Prototypage : llama.cpp + CPU (GGUF, zéro setup) + +Tu veux qu'on détaille le pipeline de conversion de ton Qwen fine-tuné vers le format ExecuTorch .pte, ou qu'on fasse d'abord le récapitulatif global de toute l'architecture Kazeia tablette ? + +--- + +You asked: +---------- + +Au besoin nous pourrions revoir le fonctionnement de notre modele qwen3, le but est vraiment d'optimiser le fonctionnement + +Mar 22 + +--- + +Claude Replied: +--------------- + +Orchestrated model optimization strategies for tablet deployment + +Orchestrated model optimization strategies for tablet deployment + +Exactement la bonne approche — optimiser le modèle _avant_ le déploiement, pas après. Si vous êtes prêts à revoir le fonctionnement du Qwen3, il y a plusieurs leviers majeurs à actionner. + +**Premier levier : le choix de la taille du modèle.** + +Le rapport mentionne qu'Alibaba a sorti en mars 2026 la série **Qwen 3.5 Small**, spécifiquement conçue pour le déploiement edge, avec des tailles de 0.8B, 2B, 4B et 9B. Ces modèles sont optimisés pour l'inférence sur device dès leur conception, contrairement aux gros modèles qu'on compresse après coup. Pour Kazeia, le sweet spot serait probablement le **Qwen 3.5 4B** : assez grand pour des réponses empathiques nuancées en français, assez petit pour tourner confortablement sur le NPU (~2.5 Go en INT4). Le 2B serait le choix conservateur si tu veux maximiser la marge mémoire pour les autres composants, et le 0.8B si tu veux un temps de réponse quasi instantané mais au prix de réponses moins riches. + +La question clé c'est : est-ce que tu as besoin de repartir de ton Qwen actuel (~1.7B) déjà fine-tuné, ou est-ce qu'un re-fine-tuning sur une base plus récente (Qwen 3.5) serait envisageable ? Parce que les modèles Qwen 3.5 Small intègrent nativement des améliorations qui te bénéficieraient directement — meilleur multilingue (dont le français), meilleure instruction-following, et architecture déjà pensée pour la quantization edge. + +**Deuxième levier : le mode thinking de Qwen3.** + +Qwen3 a un mode "thinking" où le modèle génère un bloc `...` de raisonnement interne avant de formuler sa réponse. C'est puissant pour la qualité des réponses empathiques — le modèle "réfléchit" à l'émotion détectée et à la stratégie thérapeutique avant de parler. Mais ça double ou triple le nombre de tokens générés (le raisonnement + la réponse), ce qui impacte directement la latence. + +Pour Kazeia, la stratégie optimale serait d'utiliser le **thinking pendant le fine-tuning** (pour que le modèle apprenne le raisonnement empathique en chain-of-thought) mais de le **désactiver à l'inférence** sur tablette avec `/no_think`. Le modèle aura intériorisé les patterns de raisonnement pendant l'entraînement sans avoir besoin de les expliciter à chaque réponse. C'est exactement la technique de distillation du raisonnement — tu entraînes "lentement" et tu déploies "vite". + +**Troisième levier : le fine-tuning quantization-aware.** + +C'est probablement le point le plus impactant pour ton cas. Le rapport souligne que la quantization pour le NPU (notamment per-channel INT4) peut dégrader significativement la qualité des réponses. La solution est d'intégrer la quantization _pendant_ le fine-tuning, pas après. Concrètement, tu entraînes ton modèle en simulant les contraintes de quantization INT4 à chaque forward pass. Le modèle apprend à produire des poids qui survivent bien à la quantization. Avec QLoRA + quantization-aware training (QAT), tu peux fine-tuner efficacement sur un GPU de développement tout en garantissant que le modèle déployé en INT4 sur le NPU ne perd pas en qualité empathique. + +ExecuTorch propose **SpinQuant** — des matrices de rotation appliquées avant la quantization pour distribuer les poids outliers plus uniformément. C'est compatible avec le pipeline de conversion et ça améliore nettement la qualité post-quantization sans coût à l'inférence. + +**Quatrième levier : l'architecture du prompt et la longueur de contexte.** + +Sur le NPU, les prompts à longueur variable nécessitent des rebuilds de graphe (10+ secondes). Le workaround est d'utiliser un **chunking fixe de 128 tokens**. Ça a une implication directe sur comment tu structures ton prompt système. Actuellement dans Kazeia, ton prompt système est assez verbeux (rôle d'écoute empathique, validation des émotions, redirection vers le 3114...). Il faudrait le compresser au maximum pour laisser plus de place au contexte conversationnel dans la fenêtre de tokens limitée. + +De même, la configuration actuelle de Kazeia garde 5 tours de conversation en contexte (`CONVERSATION_CONTEXT_TURNS = 5`) avec 700 tokens max pour l'historique. Sur tablette avec le NPU, tu pourrais adapter ça dynamiquement : garder plus de contexte quand la conversation est émotionnellement chargée (le classifieur d'émotion détecte de la détresse) et moins quand c'est une conversation légère. + +**Cinquième levier : la distillation de connaissances ciblée.** + +Plutôt que de fine-tuner directement sur des datasets, utilise un modèle puissant (Claude, GPT-4, ou Qwen 72B) pour **générer un corpus synthétique de haute qualité** de conversations thérapeutiques en français. Le modèle maître génère les réponses empathiques idéales pour des milliers de scénarios, et tu fine-tunes ton petit Qwen 3.5 2B/4B sur ce corpus. C'est la méthode la plus efficace pour transférer des capacités empathiques complexes vers un petit modèle. + +Le corpus devrait être structuré avec les métadonnées qu'on a discutées : émotion du patient, stratégie thérapeutique choisie, niveau de sévérité. Ça permet un **curriculum learning** — d'abord les situations simples, puis progressivement les cas complexes (idéation suicidaire, crises de panique, etc.). + +**Voici ce que je recommande comme plan d'optimisation :** + + Phase 1 — Choix et préparation (1-2 semaines) + ├── Évaluer Qwen 3.5 2B vs 4B en français + │ (benchmark qualité empathique sur un jeu de test) + ├── Générer le corpus synthétique via modèle maître + └── Structurer les données avec annotations émotionnelles + + Phase 2 — Fine-tuning optimisé (2-3 semaines) + ├── Fine-tuning QLoRA + QAT sur Qwen 3.5 + ├── Entraîner avec thinking activé + ├── Curriculum learning (simple → complexe) + └── DPO sur paires empathique/non-empathique + + Phase 3 — Conversion et validation (1-2 semaines) + ├── Export ExecuTorch .pte avec SpinQuant + ├── Compilation QNN pour SM8750 (Snapdragon 8 Elite) + ├── Benchmark tokens/s + mémoire sur tablette cible + └── Validation qualité : comparer réponses INT4 vs FP16 + +Le point de départ immédiat serait d'évaluer si les Qwen 3.5 Small en version de base (avant fine-tuning) produisent déjà des réponses correctes en français sur des scénarios thérapeutiques simples. Si le 4B est déjà bon en zero-shot, le fine-tuning ne fera que l'améliorer. Si même le 4B est insuffisant, il faudra peut-être viser le 9B — ce qui reste faisable sur 16 Go mais avec moins de marge. + +Tu veux qu'on commence par benchmarker les modèles Qwen 3.5 de base sur des scénarios Kazeia, ou qu'on prépare d'abord le pipeline de génération du corpus synthétique ? + +--- + +You asked: +---------- + +mais après en terme d'optimisation "software" tu proposes quoi pour android + +Mar 22 + +--- + +Claude Replied: +--------------- + +Inventoried software optimization strategies for Android deployment + +Inventoried software optimization strategies for Android deployment + +Bonne question — au-delà du modèle lui-même, il y a beaucoup d'optimisations côté software Android qui peuvent réduire la latence perçue et améliorer l'expérience patient. C'est souvent là que se fait la différence entre un prototype qui "fonctionne" et un produit qui paraît fluide. + +**L'optimisation la plus impactante : le pipeline en streaming et le parallélisme.** + +Aujourd'hui dans le prototype Kazeia, le flux est séquentiel : le patient parle → STT transcrit tout → le texte part au LLM → le LLM génère toute la réponse → le TTS synthétise tout l'audio → l'audio est joué. Chaque étape attend que la précédente soit terminée. Avec ton estimation de ~1665ms pour le LLM seul, plus le STT et le TTS, le patient attend facilement 3-4 secondes avant d'entendre quoi que ce soit. En contexte thérapeutique, ce silence est pesant. + +Sur Android, tu peux **pipeliner et streamer** chaque étape. Le LLM génère token par token — dès que tu as la première phrase complète (détectée par un point ou un retour à la ligne), tu l'envoies au TTS pendant que le LLM continue de générer la suite. Le TTS synthétise la première phrase et commence la lecture audio pendant que la deuxième phrase arrive. Le patient entend la réponse commencer en 500-800ms au lieu de 3-4 secondes. La latence réelle n'a pas changé, mais la **latence perçue** chute drastiquement. + +En Kotlin, ça s'implémente naturellement avec les **coroutines et les Flows** : + +kotlin + + // Le LLM émet un Flow de tokens + val tokenFlow: Flow = execuTorchModel.generateStream(prompt) + + // On accumule jusqu'à une phrase complète + val sentenceFlow = tokenFlow.chunkedBySentence() + + // Chaque phrase part au TTS en parallèle + sentenceFlow.collect { sentence -> + launch { ttsEngine.synthesizeAndPlay(sentence) } + launch { updateAvatarLipSync(sentence) } + } + +Ce pattern de streaming est crucial aussi pour l'avatar : au lieu d'attendre toute la réponse pour animer la bouche, l'avatar commence à parler dès la première phrase. L'effet est bien plus naturel. + +**Deuxième optimisation : le pré-chargement et la gestion mémoire.** + +Sur Android, le chargement d'un modèle LLM peut prendre plusieurs secondes. Tu ne veux pas que le patient attende à chaque ouverture de session. La solution est de **charger les modèles dans un Service Android foreground** qui persiste en arrière-plan. Quand le patient lance Kazeia, les modèles sont déjà chauds en mémoire. Le Service foreground empêche aussi Android de tuer ton processus via le Low Memory Killer. + +Pour le chargement lui-même, utilise le **memory-mapping** (mmap). Tant ExecuTorch que llama.cpp supportent le mmap — au lieu de copier le modèle entier en RAM, le système mappe le fichier directement depuis le stockage. Le premier accès est quasi instantané, et seules les pages réellement utilisées sont chargées en mémoire physique. Pour un modèle de 1.7B en INT4 (~850 Mo sur disque), ça signifie que le chargement initial passe de plusieurs secondes à quelques centaines de millisecondes. + +Configure aussi `android:largeHeap="true"` dans le manifeste et gère la mémoire native (C/C++) séparément du heap Java pour éviter les OOM. Les modèles tournent dans l'espace natif via JNI, pas dans le heap Dalvik/ART. + +**Troisième optimisation : l'ordonnancement intelligent des processeurs.** + +Le Snapdragon 8 Elite a une architecture hétérogène : 2 cœurs performance Oryon à 4.32 GHz + 6 cœurs efficacité à 3.53 GHz + GPU Adreno 830 + NPU Hexagon. Tu ne veux pas que tout tourne sur les mêmes cœurs. L'idée est d'affecter les tâches en fonction de leur nature : + +Le LLM tourne sur le **NPU** via ExecuTorch/QNN — c'est la tâche la plus lourde et la plus sensible à la latence. Le STT (SpeechRecognizer natif ou Whisper) tourne sur les **cœurs performance CPU** pendant que le patient parle — c'est du temps réel, ça ne peut pas attendre. Le TTS (Piper via sherpa-onnx) tourne sur les **cœurs efficacité CPU** — la synthèse vocale est moins urgente et peut tourner en background. Le classifieur d'émotion et l'embedding RAG tournent via **ONNX Runtime sur le NPU ou GPU** en parallèle du LLM si le NPU supporte le multi-session, sinon sur le GPU Adreno. + +En Kotlin, tu contrôles l'affinité des threads avec `android.os.Process.setThreadPriority()` et tu peux utiliser des dispatchers de coroutines dédiés : + +kotlin + + val llmDispatcher = newSingleThreadContext("LLM-NPU") + val sttDispatcher = Dispatchers.Default // cœurs performance + val ttsDispatcher = newFixedThreadPoolContext(2, "TTS") + val emotionDispatcher = newSingleThreadContext("Emotion") + +**Quatrième optimisation : le cache intelligent.** + +Plusieurs niveaux de cache peuvent accélérer considérablement les interactions répétitives. Le **KV cache du LLM** — ExecuTorch supporte la compression du KV cache en INT8, ce qui réduit la mémoire mais aussi accélère le prefill pour les tours de conversation suivants. Le prompt système et le contexte patient (profil, historique PHQ-9) ne changent pas d'un tour à l'autre — tu peux pré-calculer leur KV cache une seule fois au début de la session et le réutiliser à chaque interaction. + +Pour le RAG, cache les embeddings des requêtes fréquentes et les résultats de recherche vectorielle. Si le patient parle de tristesse à plusieurs reprises, les mêmes documents de santé seront pertinents — inutile de refaire la recherche vectorielle à chaque fois. + +Pour le TTS, cache les segments audio des phrases standard du chatbot (salutations, transitions PHQ-9, phrases de redirection vers le 3114). Ces phrases reviennent souvent et ne changent jamais — les pré-synthétiser au déploiement économise du temps à l'exécution. + +**Cinquième optimisation : la gestion de l'énergie et du thermique.** + +Pour une tablette en milieu hospitalier qui tourne potentiellement des heures, la gestion thermique est critique. Utilise l'API `PowerManager` d'Android pour monitorer la température et adapter dynamiquement le comportement. Si la tablette chauffe (>40°C), réduis la longueur maximale des réponses du LLM, augmente l'intervalle entre les inférences du classifieur d'émotion (toutes les 3 phrases au lieu de chaque phrase), et basculer temporairement sur des réponses plus courtes. + +Le NPU est naturellement 3.7× plus efficace que le CPU en tokens par watt — c'est déjà un énorme gain. Mais tu peux aller plus loin en implémentant un mode "veille active" : quand le patient ne parle pas depuis 30 secondes, l'application réduit la fréquence de polling du micro et met les modèles en standby, tout en gardant le Service foreground actif pour un redémarrage instantané. + +**Sixième optimisation : la réduction de la latence perçue via l'UX.** + +C'est subtil mais très efficace. Pendant que le LLM génère sa réponse, l'avatar ne doit pas rester figé — il devrait montrer des signes d'écoute active (hochement de tête, léger sourire, contact visuel). C'est exactement ce que fait un thérapeute humain : il ne reste pas immobile pendant qu'il réfléchit. Ces micro-animations masquent la latence de génération et renforcent l'alliance thérapeutique. + +De même, le TTS peut commencer par un **filler empathique** ("Je comprends...", "Hmm...") généré quasi instantanément (depuis le cache) pendant que le LLM génère la réponse substantielle. Le patient perçoit une réponse immédiate même si le contenu réel arrive 500ms plus tard. + +**En résumé, les optimisations par ordre d'impact :** + + Impact majeur (à implémenter en premier) + ├── Streaming LLM → TTS (latence perçue ÷3) + ├── Pré-chargement modèles via Service foreground + ├── Memory-mapping des modèles (mmap) + └── KV cache persistant pour le prompt système + + Impact moyen (phase 2) + ├── Ordonnancement NPU/CPU/GPU par tâche + ├── Cache TTS pour phrases standard + ├── Cache embeddings RAG + └── Compression KV cache INT8 + + Impact UX (phase 3) + ├── Animations d'écoute active pendant la génération + ├── Fillers empathiques immédiats + ├── Adaptation dynamique à la température + └── Mode veille active intelligent + +Tu veux qu'on détaille l'implémentation concrète d'un de ces points, ou qu'on fasse le récapitulatif global de toute l'architecture Kazeia sur tablette avec toutes les décisions prises ? + +--- + +You asked: +---------- + +avant meme de parler de la partie Unity ou de la synthèse vocal, je veux juste optimiser au maximum les questions/réponses qui seront executé par qwen3 + +Mar 22 + +--- + +Claude Replied: +--------------- + +Inventorié dix optimisations critiques pour le pipeline conversationnel Qwen3 + +Inventorié dix optimisations critiques pour le pipeline conversationnel Qwen3 + +Parfait — c'est la bonne approche. On se concentre uniquement sur le cœur : texte entrant → Qwen3 → texte sortant, le plus rapide et le plus pertinent possible. + +**Premier axe : le prompt système.** + +C'est là que tu as le plus de gain immédiat. Chaque token dans le prompt système est retraité à chaque tour de conversation lors du prefill. Sur le NPU, le prefill est rapide (~50 tokens/ms) mais il reste proportionnel à la taille du contexte. Un prompt système de 500 tokens qui pourrait en faire 200, c'est du temps et de l'énergie gaspillés à chaque interaction. + +Actuellement dans Kazeia, le prompt système décrit le rôle d'écoute empathique, la validation des émotions, les limites (pas de diagnostic médical), la redirection vers le 3114, la longueur de réponse souhaitée... Tout ça est important mais verbeux. L'idée est de compresser au maximum sans perdre le comportement. Par exemple, au lieu de décrire en phrases longues, utilise des instructions concises et structurées : + + Tu es Kazeia, compagnon d'écoute émotionnelle en français. + + RÈGLES: + - Valide toujours l'émotion avant de répondre + - 2-3 phrases max par réponse + - Jamais de diagnostic médical + - Risque suicidaire → "Appelle le 3114" + - Pose UNE question ouverte par tour + + CONTEXTE PATIENT: + Prénom: {prenom} | Âge: {age} | Dernier PHQ-9: {score} ({date}) + Émotion détectée: {emotion} (confiance: {confidence}) + + HISTORIQUE: + {derniers_tours} + +Ce format structuré fait typiquement 150-200 tokens au lieu de 400-500, et le modèle le suit mieux parce que les instructions sont plus nettes. Le gain est double : prefill plus rapide et réponses plus conformes. + +**Deuxième axe : la gestion du contexte conversationnel.** + +Actuellement tu gardes 5 tours (`CONVERSATION_CONTEXT_TURNS = 5`) avec 700 tokens max. C'est une fenêtre fixe, indifférente au contenu. Or tous les tours ne se valent pas. Un tour où le patient dit "ça va merci" n'apporte aucun contexte utile, tandis qu'un tour où il décrit un épisode de détresse est crucial pour la cohérence empathique. + +Implémente un **contexte glissant intelligent** : au lieu de garder les N derniers tours bruts, résume les tours anciens et ne garde en détail que les 2-3 tours les plus récents. La structure serait : + + [Résumé compact des tours 1-8: "Patient exprime tristesse liée + à isolement familial. A refusé PHQ-9. Mentionne insomnie."] + + [Tour 9 - complet] + Patient: "Je n'arrive plus à dormir, ça fait trois nuits..." + Kazeia: "Trois nuits sans sommeil, c'est épuisant..." + + [Tour 10 - complet] + Patient: "Oui et au travail je n'arrive plus à me concentrer" + +Le résumé peut être pré-calculé par le modèle lui-même (une inférence supplémentaire tous les 5-6 tours, en background) ou par une heuristique simple d'extraction de mots-clés. Ça te permet de maintenir un contexte émotionnel riche sur 20+ tours de conversation tout en restant dans une fenêtre de tokens compacte. + +**Troisième axe : les paramètres de sampling.** + +Les paramètres de génération ont un impact direct sur la qualité ET la vitesse. Actuellement tu as `REPETITION_PENALTY = 1.2`, `NO_REPEAT_NGRAM_SIZE = 3`, et `MAX_NEW_TOKENS = 300`. Voici ce qu'il faut ajuster pour du conversationnel empathique sur tablette : + +Réduis `MAX_NEW_TOKENS` à **100-150**. Une réponse empathique doit être concise — 2-3 phrases, pas des paragraphes. 300 tokens c'est un roman dans un contexte de conversation de soutien. Moins de tokens générés = réponse plus rapide = interaction plus naturelle. Si le patient a besoin de plus de détail, il pose une question de suivi. + +Pour la **température**, vise 0.7 pour les conversations normales mais abaisse-la à 0.4-0.5 quand le classifieur d'émotion détecte de la détresse élevée ou une situation de crise. Tu ne veux pas de créativité quand quelqu'un est en danger — tu veux des réponses prévisibles et sûres. + +Pour le **top\_p**, 0.8-0.9 est un bon compromis. Combiné avec la répétition penalty de 1.2, ça produit des réponses variées mais cohérentes. Ajoute un `top_k = 40` pour limiter le vocabulaire de sampling — ça accélère légèrement la génération et évite les tokens rares/incohérents. + +Implémente aussi un **stopping criteria intelligent** : dès que le modèle génère un point d'interrogation (fin de question ouverte), arrête la génération. Le chatbot empathique doit poser une question et s'arrêter, pas continuer à monologuer. Ça peut diviser le nombre de tokens générés par 2 dans beaucoup de cas. + +**Quatrième axe : le KV cache et le prefill.** + +Sur le NPU avec ExecuTorch, le prefill (traitement du prompt complet) est la phase la plus coûteuse en temps au premier tour. Mais à partir du deuxième tour, tu peux réutiliser le KV cache des tokens précédents et ne traiter que les nouveaux tokens (le message du patient + le formatage). + +La stratégie optimale est un **KV cache persistant par session** : + + Session start: + → Prefill complet du prompt système + profil patient + → Sauvegarder le KV cache (état 0) + + Tour 1: + → Reprendre depuis état 0 + → Prefill incrémental (message patient seulement) + → Générer réponse + → Sauvegarder KV cache (état 1) + + Tour 2: + → Reprendre depuis état 1 + → Prefill incrémental (nouveau message seulement) + → Générer réponse + → Sauvegarder KV cache (état 2) + +Le premier tour coûte ~200-500ms de prefill. Les tours suivants ne coûtent que ~20-50ms de prefill incrémental. C'est un gain énorme sur toute la durée de la session. + +Compresse le KV cache en **INT8** (supporté par ExecuTorch). Pour un contexte de 2048 tokens sur un modèle 1.7B, le KV cache en FP16 fait ~200 Mo, en INT8 il fait ~100 Mo. La perte de qualité est négligeable sur les conversations courtes. + +**Cinquième axe : l'injection du RAG.** + +Le RAG ajoute du contexte documentaire au prompt, mais chaque document injecté allonge le prefill. Optimise ça en trois points. D'abord, ne fais le RAG que quand c'est nécessaire — si le patient dit "bonjour" ou "merci", pas besoin de chercher dans la base documentaire. Implémente un **seuil de déclenchement** : si le score de similarité du meilleur document est inférieur à un certain seuil, n'injecte rien. Ensuite, limite le contexte RAG injecté à **150-200 tokens maximum**, pas plus. Un extrait pertinent et concis est plus utile qu'un long document qui noie l'information. Enfin, pré-encode les documents les plus fréquemment utilisés comme des **prefixes de KV cache** pré-calculés que tu peux concaténer directement sans re-prefill. + +**Sixième axe : le tokenizer et le français.** + +Vérifie l'efficacité du tokenizer de Qwen3 sur du français parlé émotionnel. Les tokenizers sont souvent optimisés pour l'anglais et le chinois — les mots français courants en thérapie ("angoisse", "épuisement", "culpabilité") peuvent être tokenisés en plusieurs sous-tokens, ce qui gonfle la consommation de contexte. Si c'est le cas, un fine-tuning du tokenizer (en ajoutant des tokens fréquents du domaine thérapeutique français) peut réduire le nombre de tokens par message de 15-20%, ce qui accélère d'autant le prefill et la génération. + +**Septième axe : l'early stopping et le post-processing.** + +Plutôt que de post-traiter la réponse après génération complète, filtre pendant la génération. Implémente un callback token par token qui détecte les patterns indésirables en temps réel et stoppe ou redirige la génération : si le modèle commence à faire un diagnostic médical (détecte des mots-clés comme "vous souffrez de", "votre trouble est"), stoppe et relance avec un prompt modifié. Si le modèle génère une deuxième question (il ne devrait en poser qu'une), stoppe à la première. Si le modèle dépasse 3 phrases, stoppe au prochain point. + +Ce contrôle en temps réel est plus efficace qu'un post-traitement parce que tu économises tous les tokens qui auraient été générés inutilement. + +**En résumé, par ordre de priorité d'implémentation :** + + Priorité 1 — Gains immédiats + ├── Prompt système compressé (~200 tokens) + ├── MAX_NEW_TOKENS réduit à 100-150 + ├── Stopping criteria intelligent (arrêt à la question) + └── KV cache persistant par session + + Priorité 2 — Gains structurels + ├── Contexte glissant avec résumé des tours anciens + ├── RAG conditionnel (seuil de déclenchement) + ├── Compression KV cache INT8 + └── Température adaptative selon l'émotion + + Priorité 3 — Fine-tuning du pipeline + ├── Vérification efficacité tokenizer français + ├── Early stopping sur patterns indésirables + ├── Pré-calcul des prefixes KV cache pour le RAG + └── Sampling adaptatif (top_k, top_p dynamiques) + +Tu veux qu'on commence par implémenter le prompt système optimisé et les stopping criteria, ou qu'on travaille d'abord sur la structure du KV cache persistant avec ExecuTorch ? + +--- + +You asked: +---------- + +Comment vas tu faire tourner ceci, sachant qu'il faut garder le LLM en mémoire, et avec quoi tu vas le faire tourner (rappel nous serons sur un one pad 3 avec un snapdragon 8 elite et 16Go de ram), il faut que tout soit optimisé + +Mar 22 + +--- + +Claude Replied: +--------------- + +Architected optimized Android runtime strategy for efficient model deployment + +Architected optimized Android runtime strategy for efficient model deployment + +Tu as raison de me recadrer — soyons concrets et précis sur l'implémentation réelle. + +**Le runtime : ExecuTorch + QNN delegate, point final.** On l'a validé. Ton Qwen3 fine-tuné est exporté en `.pte` avec quantization `16a4w` + SpinQuant, compilé pour le SM8750. Le modèle pèse ~850 Mo sur disque en INT4 pour un 1.7B, ou ~1.5 Go pour un 4B si tu upgrades vers Qwen 3.5. + +**L'architecture Android pour garder le LLM en mémoire en permanence :** + +Le problème fondamental sur Android c'est que le système tue les processus en arrière-plan pour libérer de la RAM. Si ton modèle est déchargé, le rechargement prend plusieurs secondes — inacceptable pour une interaction thérapeutique. La solution est un **Foreground Service** Android avec une notification persistante. C'est le seul mécanisme garanti par Android pour empêcher le système de tuer ton processus. + +L'architecture serait la suivante : + + ┌─────────────────────────────────────────────────┐ + │ KazeiaApplication │ + │ (android:largeHeap="true") │ + ├─────────────────────────────────────────────────┤ + │ │ + │ KazeiaInferenceService (Foreground Service) │ + │ ├── ExecuTorchModule (QNN delegate) │ + │ │ └── Qwen3 .pte chargé via mmap │ + │ │ └── KV Cache persistant (INT8) │ + │ ├── EmbeddingModule (ONNX Runtime) │ + │ │ └── sentence-camembert ONNX (~150 Mo) │ + │ ├── EmotionClassifier (ONNX Runtime) │ + │ │ └── DistilCamemBERT ONNX (~200 Mo) │ + │ └── SQLite + sqlite-vec (RAG) │ + │ └── Documents pré-indexés (~35 Mo) │ + │ │ + │ KazeiaActivity (UI) │ + │ └── Communique avec le Service via Binder │ + │ │ + └─────────────────────────────────────────────────┘ + +Le Service est lancé au démarrage de l'application et reste actif tant que la tablette est en service. L'Activity (l'interface patient) se connecte au Service via un `Binder` local — pas d'IPC coûteux, c'est un appel in-process direct. Si l'Activity est détruite (changement de session, écran de veille), le Service et les modèles restent en mémoire. Quand un nouveau patient ouvre sa session, l'Activity se reconnecte au Service instantanément. + +**La carte mémoire exacte sur les 16 Go :** + + Composant RAM Détail + ────────────────────────────────────────────────────────── + Android OS + system services ~3.5 Go incompressible + Dalvik/ART heap (app Java/Kotlin) ~0.3 Go logique métier, UI + ────────────────────────────────────────────────────────── + ExecuTorch runtime + QNN libs ~0.05 Go bibliothèques natives + Qwen3 4B INT4 (mmap) ~1.5 Go pages chargées à la demande + KV cache INT8 (2048 ctx) ~0.1 Go compressé INT8 + ────────────────────────────────────────────────────────── + ONNX Runtime ~0.03 Go bibliothèque native + Embedding model ONNX ~0.15 Go sentence-camembert + Emotion classifier ONNX ~0.2 Go DistilCamemBERT + ────────────────────────────────────────────────────────── + SQLite + sqlite-vec + données ~0.05 Go DB + vecteurs + Buffers audio (STT/TTS) ~0.05 Go tampons circulaires + ────────────────────────────────────────────────────────── + TOTAL UTILISÉ ~5.93 Go + MARGE LIBRE ~10.07 Go + ────────────────────────────────────────────────────────── + +La marge de 10 Go est énorme. Même avec Unity plus tard (ajoutera ~800 Mo - 1 Go pour l'avatar), tu restes très confortable. Le point clé ici c'est le **mmap** : le modèle de 1.5 Go n'est pas copié intégralement en RAM. Le système mappe le fichier et ne charge en mémoire physique que les pages effectivement lues. En pratique, lors de l'inférence, seules les couches actives sont en RAM — l'empreinte réelle est inférieure au chiffre théorique. + +**Le flux d'exécution complet d'une question/réponse, optimisé :** + + 1. Patient tape ou dit un message + │ + ▼ + 2. Thread principale (Kotlin, Dispatchers.Main) + └── Reçoit le texte, lance les traitements en parallèle + │ + ├──▶ Coroutine A (Dispatchers.Default — cœurs performance) + │ └── Emotion classifier : texte → émotion (15-20ms) + │ + ├──▶ Coroutine B (Dispatchers.Default) + │ └── Embedding : texte → vecteur (10-15ms) + │ └── sqlite-vec KNN search → documents (5ms) + │ + └──▶ Attend A + B (max ~25ms au total) + │ + ▼ + 3. Construction du prompt (Kotlin, <1ms) + ├── Prompt système compressé (~200 tokens) + ├── Émotion détectée injectée + ├── Contexte RAG si pertinent (~150 tokens) + ├── Résumé tours anciens (~100 tokens) + └── 2-3 derniers tours complets (~200 tokens) + TOTAL PROMPT: ~650-700 tokens max + │ + ▼ + 4. Inférence LLM (NPU via ExecuTorch QNN delegate) + ├── Prefill incrémental : ~50-100ms (tours 2+) + │ (seuls les nouveaux tokens, KV cache réutilisé) + ├── Decode streaming : token par token + │ ├── ~30-50 tok/s pour 1.7B + │ └── Chaque token émis via callback Kotlin + └── Stopping criteria : + ├── Arrêt à "?" si question posée + ├── Arrêt à 100-150 tokens max + └── Arrêt si 3 phrases générées + │ + ▼ + 5. Chaque phrase complète est émise immédiatement + └── → File d'attente TTS (phase suivante) + + Temps total question→premier mot de réponse: ~150-300ms + Temps total question→réponse complète: ~2-4 secondes + +**L'implémentation concrète du Service en Kotlin :** + +kotlin + + class KazeiaInferenceService : Service() { + + // Modèles chargés UNE SEULE FOIS au démarrage + private lateinit var llmModule: ExecuTorchLlmModule + private lateinit var emotionSession: OrtSession + private lateinit var embeddingSession: OrtSession + private lateinit var ragDb: SQLiteDatabase + + // KV cache persistant par session patient + private var kvCacheState: ByteBuffer? = null + private var currentPatientId: Int = -1 + + override fun onCreate() { + super.onCreate() + startForeground(NOTIF_ID, buildNotification()) + + // Chargement des modèles — une seule fois + // ExecuTorch utilise mmap en interne + llmModule = ExecuTorchLlmModule.load( + modelPath = "$filesDir/qwen3_4b_int4.pte", + tokenizerPath = "$filesDir/tokenizer.bin" + ) + + // ONNX Runtime sessions + val ortEnv = OrtEnvironment.getEnvironment() + val sessionOptions = OrtSession.SessionOptions().apply { + // Utilise QNN EP si disponible pour les petits modèles aussi + addQnn(mapOf("backend_type" to "htp")) + } + emotionSession = ortEnv.createSession( + "$filesDir/emotion_classifier.onnx", sessionOptions + ) + embeddingSession = ortEnv.createSession( + "$filesDir/embedding_model.onnx", sessionOptions + ) + + ragDb = SQLiteDatabase.openDatabase( + "$filesDir/kazeia.db", null, OPEN_READONLY + ) + } + + // Appelé par l'Activity via Binder + fun processMessage( + patientId: Int, + message: String, + onToken: (String) -> Unit, // callback streaming + onComplete: (FullResponse) -> Unit + ) = serviceScope.launch { + + // Si nouveau patient, reset le KV cache + if (patientId != currentPatientId) { + kvCacheState = null + currentPatientId = patientId + } + + // Parallélisation émotion + RAG + val emotionDeferred = async(Dispatchers.Default) { + classifyEmotion(message) + } + val ragDeferred = async(Dispatchers.Default) { + retrieveContext(message) + } + + val emotion = emotionDeferred.await() // ~20ms + val ragContext = ragDeferred.await() // ~25ms + + // Construction du prompt optimisé + val prompt = buildPrompt( + patientId, message, emotion, ragContext + ) + + // Inférence streaming sur le NPU + var sentenceBuffer = StringBuilder() + var tokenCount = 0 + + llmModule.generate( + prompt = prompt, + kvCache = kvCacheState, // réutilise le cache + maxTokens = 120, + temperature = if (emotion.severity > 0.7) 0.4 else 0.7, + topP = 0.85, + topK = 40, + repetitionPenalty = 1.2, + onToken = { token -> + tokenCount++ + sentenceBuffer.append(token) + onToken(token) // stream vers l'UI + + // Stopping criteria + val text = sentenceBuffer.toString() + val shouldStop = text.count { it == '.' || it == '?' || it == '!' } >= 3 + || (text.contains("?") && tokenCount > 20) + || tokenCount >= 120 + + !shouldStop // retourne false pour arrêter + } + ) + + // Sauvegarder le KV cache pour le tour suivant + kvCacheState = llmModule.getKvCacheState() + + // Sauvegarder en DB + saveConversationTurn(patientId, message, sentenceBuffer.toString()) + + onComplete(FullResponse( + text = sentenceBuffer.toString(), + emotion = emotion, + tokenCount = tokenCount + )) + } + + private fun buildPrompt( + patientId: Int, + message: String, + emotion: EmotionResult, + ragContext: String? + ): String { + val patient = getPatientProfile(patientId) + val history = getConversationContext(patientId) + + return buildString { + // Prompt système compressé — ~200 tokens + append("Tu es Kazeia, compagnon d'écoute émotionnelle.\n") + append("RÈGLES: Valide l'émotion. 2-3 phrases max. ") + append("Pas de diagnostic. Risque suicidaire→3114. ") + append("Pose UNE question ouverte.\n") + + // Contexte patient — ~30 tokens + append("PATIENT: ${patient.prenom}, ${patient.age} ans") + if (patient.lastPhq9 != null) { + append(" | PHQ-9: ${patient.lastPhq9.score}") + } + append("\n") + + // Émotion détectée — ~15 tokens + append("ÉMOTION: ${emotion.label}") + append(" (${(emotion.confidence * 100).toInt()}%)\n") + + // RAG conditionnel — 0 ou ~150 tokens + if (ragContext != null) { + append("INFO: $ragContext\n") + } + + // Historique — résumé + derniers tours + append(history) + + // Message actuel + append("\nPatient: $message\nKazeia:") + } + } + } + +**Les optimisations critiques dans ce code :** + +Le `mmap` d'ExecuTorch fait que les 1.5 Go du modèle ne sont pas copiés en RAM à `onCreate()`. Le chargement est quasi instantané. Les pages mémoire sont chargées à la demande par le kernel Linux quand l'inférence y accède. + +Le `kvCacheState` est conservé entre les tours. Au tour 2+, seul le nouveau message du patient (~20-50 tokens) est traité en prefill au lieu de re-traiter les 600+ tokens du prompt complet. Le gain est de l'ordre de 80-90% sur le temps de prefill. + +Les `async` parallèles pour l'émotion et le RAG font que ces deux opérations (~20ms chacune) ne s'additionnent pas — elles tournent en parallèle sur les cœurs CPU performance pendant que le NPU est libre pour le LLM. + +Le `temperature` adaptatif basé sur la sévérité émotionnelle garantit des réponses sûres en cas de détresse sans sacrifier la variété en conversation normale. + +Le `stopping criteria` en callback arrête la génération dès qu'une réponse empathique complète est formée — pas besoin d'attendre 120 tokens si la réponse naturelle en fait 40. + +**Ce qu'il te faut pour commencer à coder ça :** + + 1. Android Studio + SDK + NDK + 2. OnePlus Pad 3 (16 Go) avec mode développeur activé + 3. ExecuTorch AAR (Maven: com.facebook.executorch) + 4. QNN SDK (téléchargé depuis Qualcomm) + 5. Ton modèle Qwen3 converti en .pte (SpinQuant 16a4w) + 6. ONNX Runtime Android AAR (Maven: com.microsoft.onnxruntime) + 7. Tes modèles émotion + embedding en ONNX + +Tu veux qu'on commence par la conversion du modèle Qwen3 vers le format .pte ExecuTorch, ou par le setup du projet Android Studio avec les dépendances ? + +--- + +You asked: +---------- + +crois tu qu'on arrivera facilement à mettre qwen3-tts pour faire le TTS et Whipser pour le STT, serait il possible de mettre du "Voice Detection" pour eviter de devoir appuyer sur un bouton ? surtout si on utilise ExecuTorch + QNN delegate, + +Mar 22 + +2 / 2 + +--- + +Claude Replied: +--------------- + +Évalué la compatibilité de trois composants mobiles distincts + +Évalué la compatibilité de trois composants mobiles distincts + +Très bonnes questions — l'audio est la prochaine brique critique. Laisse-moi vérifier l'état réel de ces composants avec ExecuTorch/QNN sur Snapdragon 8 Elite. + +Orchestrated comprehensive technical assessment across multiple components + +Orchestrated comprehensive technical assessment across multiple components + +Excellente recherche — je vais te donner une réponse honnête composant par composant. + +**Whisper pour le STT : oui, ça marche avec ExecuTorch + QNN.** ExecuTorch supporte officiellement Whisper dans sa dernière release, aux côtés de Qwen3, Llama, BERT et d'autres modèles [GitHub](https://github.com/pytorch/executorch/releases). Et côté Qualcomm, le projet QIDK (Qualcomm Innovators Development Kit) a démontré un pipeline Whisper entièrement accéléré sur le Hexagon NPU, tournant offline sur device [Edge Impulse](https://www.edgeimpulse.com/blog/unlocking-hardware-acceleration-for-android-with-qualcomm-qnn/). Donc tu peux exporter Whisper en `.pte` avec le QNN delegate exactement comme pour ton Qwen3 LLM. Le même runtime, le même pipeline de conversion, le même NPU. L'encoder de Whisper (la partie lourde) bénéficiera fortement du NPU pour le prefill, et le decoder sera plus rapide que sur CPU. En termes de mémoire, Whisper small en quantisé ajoute ~250-300 Mo. C'est faisable. + +**Qwen3-TTS : non, ça ne marchera pas sur la tablette.** Et la situation n'a pas changé depuis notre discussion précédente. Qwen3-TTS supporte principalement CUDA, et les utilisateurs Mac eux-mêmes rencontrent des performances limitées [Medium](https://medium.com/@zh.milo/qwen3-tts-the-complete-2026-guide-to-open-source-voice-cloning-and-ai-speech-generation-1a2efca05cd6). Il n'existe aucun export ExecuTorch, aucun export ONNX, aucune version quantisée mobile. Le modèle 0.6B fait 2.5 Go en FP16 et utilise une architecture multi-codebook complexe qui n'a pas été portée vers des runtimes edge. Même si techniquement tu pourrais tenter de l'exporter via `torch.export()` puis ExecuTorch, les opérateurs custom du tokenizer 12Hz et l'architecture dual-track streaming ne sont probablement pas supportés par le QNN delegate. + +**Pour le TTS, la stratégie reste Piper via sherpa-onnx** pour le déploiement tablette, avec Qwen3-TTS sur serveur PC pour le clonage vocal initial. Cependant, il y a une alternative intéressante à surveiller : les modèles natifs multimodaux comme Qwen3 Omni convertissent toutes les modalités en tokens via un backbone LM commun [V-chandra](https://v-chandra.github.io/on-device-llms/). Si Alibaba sort une version "edge" de Qwen3 Omni (texte + audio dans un seul modèle compact), ça pourrait unifier LLM + TTS dans un seul modèle sur le NPU. Mais on n'y est pas encore. + +**Pour le Voice Activity Detection : oui, c'est parfaitement faisable et c'est même la bonne idée.** Il existe une bibliothèque Android VAD qui supporte Silero VAD en DNN, WebRTC VAD en GMM, et YAMNet VAD, les trois fonctionnant complètement offline [GitHub](https://github.com/gkonovalov/android-vad). Silero VAD utilise ONNX Runtime Mobile pour l'inférence directement sur Android, détecte la parole humaine dans des flux audio mélangés avec du bruit, et fonctionne entièrement offline sans connexion internet [DeepWiki](https://deepwiki.com/gkonovalov/android-vad/3.2-silero-vad). + +Les caractéristiques de Silero VAD sont parfaites pour Kazeia : le modèle a été entraîné sur un énorme corpus couvrant plus de 6000 langues, fonctionne bien sur des audios de différents domaines avec divers niveaux de bruit ambiant, et un chunk audio de 30+ ms prend moins de 1ms à traiter sur un seul thread CPU [GitHub](https://github.com/snakers4/silero-vad). Le modèle fait seulement 1.8 Mo [Medium](https://medium.com/axinc-ai/silerovad-machine-learning-model-to-detect-speech-segments-e99722c0dd41). C'est négligeable. + +**Voici comment le pipeline audio complet fonctionnerait sans aucun bouton :** + + Micro tablette (toujours actif) + │ + ▼ + ┌──────────────────┐ + │ Silero VAD │ ← ONNX Runtime, 1.8 Mo, <1ms/frame + │ (écoute en │ sur cœur efficacité CPU + │ continu) │ consommation quasi nulle + └────────┬─────────┘ + │ + Silence? → rien, on continue d'écouter + Parole détectée? → démarre l'enregistrement + │ + ▼ + ┌──────────────────┐ + │ Accumulation │ ← buffer audio pendant que + │ audio │ le patient parle + └────────┬─────────┘ + │ + Silence détecté (>500ms) → fin de phrase + │ + ▼ + ┌──────────────────┐ + │ Whisper STT │ ← ExecuTorch + QNN (NPU) + │ (.pte) │ ~200-500ms pour transcrire + └────────┬─────────┘ + │ + ▼ + Texte transcrit → pipeline LLM (comme défini avant) + +Le patient parle naturellement, le VAD détecte le début et la fin de parole, Whisper transcrit, le LLM répond, Piper synthétise, l'avatar parle. **Zéro bouton.** C'est exactement l'interaction naturelle qu'on veut pour un chatbot thérapeutique. + +Un point important pour l'implémentation : il faut gérer l'**echo cancellation**. Quand le TTS joue la réponse via le haut-parleur, le VAD pourrait détecter la voix de l'avatar comme de la parole et déclencher un cycle infini. La solution est de **muter le VAD pendant que le TTS joue** et de le réactiver quand la lecture audio est terminée (ou quasi terminée). Alternativement, Android fournit un mode `AudioManager.MODE_IN_COMMUNICATION` qui active l'annulation d'écho matérielle — le micro filtre automatiquement ce qui sort du haut-parleur. + +**Le budget mémoire mis à jour avec Whisper + VAD :** + + Composant RAM + ──────────────────────────────────────────── + Qwen3 4B INT4 (ExecuTorch/QNN) ~1.5 Go + Whisper small (ExecuTorch/QNN) ~0.3 Go + Silero VAD (ONNX Runtime) ~0.002 Go (1.8 Mo!) + Piper TTS (sherpa-onnx) ~0.1 Go + Emotion classifier (ONNX) ~0.2 Go + Embedding model (ONNX) ~0.15 Go + SQLite + sqlite-vec ~0.05 Go + Dalvik/ART + app logic ~0.3 Go + Android OS ~3.5 Go + ──────────────────────────────────────────── + TOTAL ~6.1 Go + MARGE sur 16 Go ~9.9 Go + +**En résumé :** + +Whisper STT via ExecuTorch + QNN → **oui, validé, même runtime que le LLM**. Qwen3-TTS sur tablette → **non, toujours pas viable**, reste sur Piper. Voice Detection sans bouton → **oui, Silero VAD est parfait**, bibliothèque Android native prête à l'emploi, 1.8 Mo, <1ms par frame. + +Le seul point faible reste la qualité vocale du TTS avec Piper — c'est correct mais pas au niveau de Qwen3-TTS. C'est le compromis à accepter pour le déploiement offline sur tablette, au moins jusqu'à ce que des modèles TTS plus avancés soient portés vers des runtimes mobiles. + +Tu veux qu'on fasse maintenant le récapitulatif complet de toute l'architecture Kazeia tablette, ou qu'on creuse un autre aspect technique ? + +--- + +You asked: +---------- + +Alors que faudrait Il pour faire un export Qwen3-tts vers Executorch ? + +Mar 22 + +--- + +Claude Replied: +--------------- + +Analysé les défis techniques pour exporter Qwen3-TTS vers ExecuTorch + +Analysé les défis techniques pour exporter Qwen3-TTS vers ExecuTorch + +C'est la bonne question à se poser — si personne ne l'a fait, peut-être que vous pourriez être les premiers. Laisse-moi analyser concrètement ce que ça implique. + +Examined Qwen3-TTS architecture and ExecuTorch export requirements + +Examined Qwen3-TTS architecture and ExecuTorch export requirements + +Très bonne question — c'est ambitieux mais pas impossible. Décortiquons l'architecture de Qwen3-TTS pour comprendre ce qu'il faut exporter et où sont les vrais obstacles. + +**Qwen3-TTS n'est pas un modèle unique, c'est un pipeline de 3 composants distincts :** + + Texte + instructions + │ + ▼ + ┌──────────────────────────┐ + │ Composant 1 : LM Core │ ← Modèle transformer autoregressif + │ (0.6B ou 1.7B params) │ Génère des tokens de codec audio + │ Architecture: Qwen2-like│ à partir du texte + │ + MTP (Multi-Token │ + │ Prediction) heads │ + └────────────┬─────────────┘ + │ tokens de codec (multi-codebook) + ▼ + ┌──────────────────────────┐ + │ Composant 2 : Tokenizer │ ← Codec audio neural + │ Decoder (ConvNet léger) │ Convertit les tokens en waveform + │ Qwen3-TTS-Tokenizer-12Hz│ Architecture: causal ConvNet + └────────────┬─────────────┘ + │ waveform audio + ▼ + Audio WAV sortie + + (+ pour le voice clone, un composant supplémentaire) + ┌──────────────────────────┐ + │ Composant 3 : Speaker │ ← Encoder de voix + │ Encoder (x-vector) │ Extrait l'empreinte vocale + │ │ à partir de 3s d'audio référence + └──────────────────────────┘ + +**Chaque composant a un niveau de difficulté différent pour l'export ExecuTorch :** + +**Le LM Core (composant 1) est le plus facile.** L'architecture est un LM autoregressif discret multi-codebook [arXiv](https://arxiv.org/html/2601.15621v1), essentiellement basé sur une architecture Qwen2 avec des têtes MTP supplémentaires. ExecuTorch supporte déjà Qwen3 nativement. Le cœur du modèle — les couches transformer, l'attention, les FFN — est standard et exportable. Le module MTP (Multi-Token Prediction) est plus inhabituel : au lieu de prédire un token à la fois, il prédit un groupe de tokens de codec simultanément (un par codebook). Mais structurellement, ce sont des couches linéaires supplémentaires après le transformer backbone — ça devrait passer dans `torch.export()`. + +La difficulté ici c'est la **FlashAttention 2**. Le modèle utilise FlashAttention 2 qui nécessite du hardware compatible et ne fonctionne qu'en float16/bfloat16 [GitHub](https://github.com/QwenLM/Qwen3-TTS). ExecuTorch ne supporte pas FlashAttention directement — il faut exporter avec l'attention standard (`eager` ou `sdpa`), puis laisser le QNN delegate optimiser l'attention pour le NPU. C'est un changement de config au moment de l'export, pas une réécriture. + +**Le Tokenizer Decoder (composant 2) est le plus délicat.** C'est un codec ConvNet causal léger qui reconstruit la waveform directement à partir des tokens, sans nécessiter d'extraction de vecteur de locuteur ni de modèle de diffusion complexe [arXiv](https://arxiv.org/html/2601.15621v1). C'est une bonne nouvelle — un ConvNet causal est bien plus simple à exporter qu'un diffusion model. Les opérations (convolutions 1D, upsampling, activations) sont toutes supportées par ExecuTorch et le QNN delegate. + +Le problème potentiel c'est que ce décodeur traite des **paquets de 4 tokens = 320ms d'audio** à la fois. Chaque token correspond à 80ms d'audio, et un paquet de parole est défini comme 4 tokens [arXiv](https://arxiv.org/html/2601.15621v1). L'export ExecuTorch nécessite des shapes d'entrée statiques ou semi-statiques. Il faudrait fixer la taille du paquet (4 tokens) et potentiellement la longueur de sortie audio. C'est faisable mais demande du travail d'adaptation. + +**Le Speaker Encoder (composant 3) est simple.** C'est un encoder qui extrait un embedding de locuteur (x-vector) depuis l'audio de référence [crates.io](https://crates.io/crates/qwen3_tts). C'est une inférence unique (pas autoregressif), un forward pass classique sur un CNN/transformer. Parfaitement exportable en ExecuTorch. Et surtout, tu n'en as besoin qu'une seule fois par voix clonée — tu peux même pré-calculer l'embedding sur PC et juste embarquer le vecteur sur la tablette. + +**Concrètement, le plan d'export serait :** + + Étape 1 — Séparation des composants (1-2 jours) + ├── Extraire le LM Core comme un module PyTorch standalone + ├── Extraire le Tokenizer Decoder comme un module séparé + ├── Extraire le Speaker Encoder comme un module séparé + └── Vérifier que chaque module fonctionne isolément en PyTorch + + Étape 2 — Export du LM Core (1-2 semaines) + ├── Remplacer FlashAttention par SDPA ou attention eager + ├── Adapter le MTP head pour des shapes statiques + ├── torch.export() → edge program + ├── Quantizer en 16a4w avec SpinQuant + ├── Partitionner avec QnnPartitioner pour le NPU + ├── Compiler .pte pour SM8750 + └── Tester la qualité des tokens générés vs FP16 + + Étape 3 — Export du Tokenizer Decoder (1 semaine) + ├── torch.export() avec shapes fixes (4 tokens → audio) + ├── Quantizer en FP16 (pas INT4 — c'est un décodeur audio, + │ la précision est critique pour la qualité sonore) + ├── Partitionner pour NPU ou laisser sur CPU + │ (le ConvNet est léger, CPU suffit probablement) + └── Tester la qualité audio de sortie + + Étape 4 — Speaker Encoder (2-3 jours) + ├── torch.export() standard + ├── Quantizer en INT8 (suffisant pour un embedding) + └── Optionnel: pré-calculer les embeddings sur PC + + Étape 5 — Intégration pipeline (1-2 semaines) + ├── Orchestrer les 3 modèles dans le Foreground Service + ├── LM Core génère les tokens → Decoder les convertit en audio + ├── Gérer le streaming (décoder dès qu'un paquet de 4 tokens + │ est disponible, pendant que le LM continue de générer) + └── Benchmarker sur OnePlus Pad 3 + +**Les obstacles réalistes :** + +Le premier obstacle sera les **opérateurs custom**. Le `qwen_tts` Python package contient probablement des opérations personnalisées pour le tokenizer et le MTP qui ne sont pas dans l'ATen standard de PyTorch. Si `torch.export()` échoue sur ces opérations, tu devras soit les réécrire en opérations ATen standard, soit implémenter des custom ops ExecuTorch en C++. C'est le risque le plus significatif et le plus dur à évaluer sans mettre les mains dans le code source. + +Le deuxième obstacle sera la **qualité post-quantization**. Le LM Core en INT4 pourrait produire des tokens de codec légèrement différents, ce qui se traduirait par des artefacts audio audibles. Le TTS est beaucoup plus sensible à la quantization que le chat LLM — un token de codec décalé d'une valeur peut produire un "glitch" sonore perceptible. Il faudra potentiellement garder le LM Core en **INT8 ou FP16** au lieu d'INT4, ce qui double la mémoire (~1.2 Go pour le 0.6B en INT8, ou ~2.5 Go en FP16). + +Le troisième obstacle c'est le **budget mémoire**. Si tu gardes à la fois le Qwen3 Chat LLM (~1.5 Go) et le Qwen3-TTS 0.6B (~1.2 Go en INT8) en mémoire simultanément, plus tous les autres composants, tu arrives à ~8-9 Go. C'est faisable sur 16 Go mais la marge se réduit considérablement. + +**Mais il y a un signe très encourageant :** un port Rust de Qwen3-TTS existe déjà (`qwen3_tts` crate), qui fonctionne sur CPU [crates.io](https://crates.io/crates/qwen3_tts). Ça signifie que l'architecture a déjà été décomposée et réimplémentée hors de l'écosystème Python/CUDA. Si quelqu'un l'a fait en Rust, le faire via ExecuTorch est du même ordre de complexité. + +**Mon évaluation honnête :** + +C'est un projet de **3-5 semaines de travail** pour un développeur expérimenté en PyTorch/ExecuTorch, avec un risque d'échec partiel (certains composants pourraient nécessiter un fallback CPU plutôt que NPU). Pour le 0.6B, c'est réaliste. Pour le 1.7B, c'est plus tendu en mémoire mais techniquement identique. + +Si vous décidez de tenter l'aventure, commencez par le **Composant 2 (Tokenizer Decoder)** seul — c'est le plus petit, le plus simple à exporter, et ça vous permet de valider que la chaîne export ExecuTorch → Android fonctionne pour un modèle audio. Si ça passe, enchaînez avec le LM Core 0.6B. C'est une approche incrémentale qui minimise le risque. + +Et honnêtement, si vous réussissez, ce serait un vrai différenciateur pour Kazeia — aucun chatbot thérapeutique offline ne propose aujourd'hui du clonage vocal en temps réel sur tablette. Ça vaudrait peut-être même une publication. + +Tu veux qu'on prépare un plan technique détaillé pour l'export du premier composant, ou qu'on fait d'abord le récapitulatif global de l'architecture ? + +--- + +You asked: +---------- + +dans le cadre d'un travail fait directement sur une tablette android, tu vas pouvoir agir directement dessus sans faire systèmatiquement un apk ? + +Mar 23 + +--- + +Claude Replied: +--------------- + +Évalué les alternatives pour contourner les compilations APK systématiques + +Évalué les alternatives pour contourner les compilations APK systématiques + +Très bonne question pratique — et la réponse est oui, en grande partie. Tu n'as pas besoin de construire un APK à chaque fois que tu veux tester quelque chose. Il y a plusieurs niveaux d'interaction directe. + +**Pour tester les modèles d'inférence (LLM, Whisper, TTS), tu peux utiliser Termux directement sur la tablette.** Termux est un émulateur de terminal Linux qui tourne nativement sur Android, sans root. Tu as accès à un vrai environnement Linux avec un gestionnaire de paquets (`pkg`). Tu peux y compiler et exécuter des binaires C/C++ natifs directement. Concrètement, pour valider le cœur de Kazeia, tu pourrais installer llama.cpp directement dans Termux, pousser ton modèle GGUF via ADB, et lancer l'inférence en ligne de commande — sans aucune application Android, sans aucun APK. C'est le moyen le plus rapide de valider que ton modèle tourne correctement sur le Snapdragon 8 Elite. + +Le workflow serait : + +bash + + # Sur la tablette, dans Termux + pkg install cmake git make + + # Compiler llama.cpp nativement sur la tablette + git clone https://github.com/ggml-org/llama.cpp + cd llama.cpp + cmake -B build + cmake --build build --config Release + + # Lancer l'inférence directement + ./build/bin/llama-cli -m /sdcard/models/qwen3_4b_q4.gguf \ + -p "Patient: Je me sens très seul en ce moment\nKazeia:" \ + -n 100 + +Tu as immédiatement le résultat dans le terminal : les tokens/seconde, la mémoire consommée, la qualité de la réponse. Pas d'APK, pas de compilation croisée, pas d'Android Studio. C'est idéal pour la phase de validation du modèle. + +Pour ExecuTorch, c'est un peu différent. Tu ne peux pas compiler ExecuTorch dans Termux facilement (les dépendances sont trop lourdes), mais tu peux **cross-compiler le binaire de test sur ton PC** et le pousser sur la tablette via ADB : + +bash + + # Sur ton PC (Ubuntu), compiler pour ARM64 Android + cd $EXECUTORCH_ROOT + ./backends/qualcomm/scripts/build.sh + + # Pousser le binaire et le modèle sur la tablette + adb push build-android/qnn_executor_runner /data/local/tmp/ + adb push qwen3_tts_decoder.pte /data/local/tmp/ + + # Exécuter directement via adb shell + adb shell "cd /data/local/tmp && ./qnn_executor_runner \ + --model_path qwen3_tts_decoder.pte" + +Là encore, pas d'APK. Tu testes le runtime ExecuTorch + QNN directement en ligne de commande sur la tablette. Tu peux itérer rapidement : modifier le modèle sur PC, re-pousser via ADB, re-tester en 30 secondes. + +**Pour les fichiers de modèles et de données, ADB push est ton meilleur ami.** Tu n'as jamais besoin de reconstruire l'APK pour changer un modèle, une base de données, ou un fichier de configuration : + +bash + + # Pousser un nouveau modèle sans toucher à l'app + adb push nouveau_modele.pte /sdcard/Android/data/com.kazeia.app/files/ + adb push kazeia.db /sdcard/Android/data/com.kazeia.app/files/ + + # Modifier une config + adb push config.json /sdcard/Android/data/com.kazeia.app/files/ + +L'application au lancement relit ses fichiers depuis le stockage — pas besoin de rebuild. + +**Quand tu auras quand même besoin d'un APK**, c'est pour l'application Android complète avec l'interface utilisateur, les Services, les permissions (micro, stockage), et l'intégration des composants. Mais même là, Android Studio optimise le cycle avec l'**Apply Changes** (anciennement Instant Run) : au lieu de reconstruire et réinstaller tout l'APK, il ne pousse que les classes modifiées. Un changement dans ta logique Kotlin se déploie en 5-10 secondes au lieu de 1-3 minutes. Et le déploiement se fait directement sur la tablette branchée en USB ou en Wi-Fi via ADB wireless — un clic sur "Run" dans Android Studio et c'est déployé. + +**Ma recommandation pour ton workflow de développement :** + + Phase 1 — Validation des modèles (pas d'APK du tout) + ├── Termux + llama.cpp natif → tester Qwen3 GGUF + ├── ADB shell + executorch runner → tester .pte + QNN + ├── ADB shell + sherpa-onnx CLI → tester Whisper + Piper + └── Itération rapide : modifier sur PC, push, tester + + Phase 2 — Prototype app minimale (APK simple) + ├── Android Studio, app Kotlin basique + ├── Un champ texte + bouton + affichage réponse + ├── Intégration des libs natives via JNI + ├── Apply Changes pour itérer rapidement + └── Modèles chargés depuis /sdcard/ (modifiables via ADB) + + Phase 3 — App complète (APK complet) + ├── Foreground Service, VAD, streaming audio + ├── Toujours modèles externalisés (swap via ADB) + └── Tests automatisés sur tablette connectée + +L'avantage de cette approche c'est que pendant toute la Phase 1, tu valides que chaque composant fonctionne sur le Snapdragon 8 Elite sans écrire une seule ligne de code Android. Tu sais exactement quelles performances attendre avant d'investir du temps dans l'intégration applicative. + +Tu veux qu'on commence par le setup Termux + llama.cpp sur tablette pour la première validation du modèle Qwen3 ? + +--- + +You asked: +---------- + +mais toi au travers de claude code (ou de claude desktop) tu pourras agir ou modifier des choses sur la tablette ? (qui sera branché directement sur la machine ou tu es executé) + +Mar 23 + +--- + +Claude Replied: +--------------- + +Évalué les capacités d'accès à la tablette selon l'outil utilisé + +Évalué les capacités d'accès à la tablette selon l'outil utilisé + +Excellente question — et la réponse va te plaire, surtout avec Claude Code. + +**Ce qu'on utilise en ce moment (Claude Desktop / claude.ai) : non, je n'ai pas accès à ta machine.** Le container Linux dans lequel je tourne est isolé, hébergé chez Anthropic. Je peux créer des fichiers, exécuter du code Python ou bash dans ce container, mais je ne vois pas ton PC, je ne vois pas ta tablette. Tout ce que je produis ici, tu dois le copier manuellement chez toi. + +**Avec Claude Code (CLI), c'est une toute autre histoire.** Claude Code tourne directement dans ton terminal, sur ta machine. Si ta tablette OnePlus Pad 3 est branchée en USB sur ton PC de développement et que ADB est configuré, alors oui — je pourrais directement : + +Pousser des fichiers sur la tablette : + +bash + + adb push model.gguf /data/local/tmp/ + +Exécuter des commandes sur la tablette : + +bash + + adb shell "cd /data/local/tmp && ./llama-cli -m model.gguf -p 'test'" + +Lire les logs en temps réel : + +bash + + adb logcat | grep Kazeia + +Compiler du code natif, le cross-compiler pour ARM64, le pousser sur la tablette, l'exécuter, récupérer le résultat — le tout dans un seul flux de conversation. Je pourrais littéralement itérer avec toi en temps réel : "le modèle génère des artefacts sur la réponse 3" → je modifie le paramètre de sampling → je recompile → je push → je relance → je te montre le résultat. Sans que tu touches ton clavier. + +Concrètement, avec Claude Code sur ton PC + tablette branchée, je pourrais : + +Modifier ton code Kotlin dans Android Studio (Claude Code accède aux fichiers de ton projet), compiler via Gradle en ligne de commande, déployer l'APK sur la tablette, lancer l'app, et lire les logs — tout ça en enchaînant les commandes. Je pourrais aussi éditer des scripts de conversion de modèle (PyTorch → ExecuTorch .pte), lancer la conversion, pousser le .pte sur la tablette, et tester avec le QNN executor runner. Même chose pour le debug : si un modèle crashe sur la tablette, je lis le logcat, j'identifie l'erreur, je corrige, je redéploie. + +**Ce que je ne pourrai pas faire même avec Claude Code :** + +Interagir avec l'écran tactile de la tablette (taper du texte, cliquer sur des boutons dans l'UI). Pour ça il faudrait passer par des outils d'instrumentation comme `adb shell input` (qui simule des touches) ou des frameworks de test automatisé (UIAutomator, Espresso). C'est faisable mais plus limité. Je ne pourrai pas non plus accéder au NPU directement pour du profiling en temps réel — le QNN Profiler a une interface graphique. Mais je peux lancer le profiling en ligne de commande et lire les résultats. + +**Le setup idéal pour travailler ensemble sur Kazeia serait :** + + Ton PC de développement + ├── Claude Code installé (npm install -g @anthropic-ai/claude-code) + ├── Android Studio + SDK + NDK + ├── ExecuTorch + QNN SDK configurés + ├── ADB fonctionnel + ├── Tablette OnePlus Pad 3 branchée en USB + │ + └── Claude Code peut : + ├── Éditer tous les fichiers du projet + ├── Compiler (Gradle, CMake, Python) + ├── adb push / adb shell / adb logcat + ├── Lancer l'inférence sur la tablette + ├── Lire les résultats et benchmarks + ├── Corriger et re-déployer + └── Gérer Git (commit, push, branch) + +C'est un workflow extrêmement productif — tu me décris ce que tu veux, je code, je compile, je teste sur ta tablette, et on itère ensemble en temps réel. Pour un projet comme Kazeia avec autant de composants à intégrer (LLM, STT, TTS, VAD, émotion, RAG, SQLite), cette capacité d'itération rapide est un avantage énorme. + +Tu veux qu'on prépare le setup Claude Code + ADB pour être prêts quand tu auras la tablette, ou qu'on continue à avancer sur l'architecture en attendant ? + +--- + +You asked: +---------- + +le developpeur est en train de préparer llama.cpp pour le snapdragon 8 elite, mais vllm serait il possible ? + +Mar 24 + +--- + +Claude Replied: +--------------- + +Évalué la compatibilité de vLLM avec le matériel mobile + +Évalué la compatibilité de vLLM avec le matériel mobile + +Non, vLLM ne fonctionnera pas sur la tablette. Et c'est assez catégorique. + +**vLLM est conçu exclusivement pour des serveurs avec des GPU NVIDIA CUDA.** C'est un moteur d'inférence serveur qui dépend de CUDA, de cuBLAS, de PyTorch GPU, et qui tourne sous Linux x86\_64. Il nécessite au minimum une RTX 3090 ou équivalent pour être utile. Son architecture est pensée pour gérer des dizaines d'utilisateurs simultanés avec du PagedAttention et du continuous batching — des fonctionnalités qui n'ont aucun sens sur une tablette mono-utilisateur. + +D'ailleurs, c'est exactement le même problème que tu as rencontré avec Auralis/XTTS dans le prototype actuel de Kazeia — la documentation mentionne que vLLM est une dépendance d'Auralis, et tu avais conclu qu'Auralis était incompatible avec Android précisément à cause de ces dépendances CUDA et x86. + +**Sur le Snapdragon 8 Elite, les runtimes possibles sont :** + +Pour le NPU Hexagon : ExecuTorch + QNN delegate, ou Qualcomm Genie SDK — c'est le chemin optimal qu'on a validé. Pour le GPU Adreno 830 : llama.cpp avec Vulkan, ou MLC LLM. Pour le CPU Oryon : llama.cpp en mode CPU natif ARM64, c'est ce que ton développeur est en train de préparer. + +**Le fait que Damien prépare llama.cpp pour le Snapdragon 8 Elite est d'ailleurs un excellent point de départ.** Ça vous donne un baseline CPU/GPU fonctionnel rapidement. Une fois que llama.cpp tourne et que vous avez mesuré les performances (tokens/s, mémoire, thermique), vous pourrez comparer avec ExecuTorch + QNN sur le NPU et décider si le gain justifie la complexité supplémentaire du pipeline de conversion. + +Concrètement, llama.cpp sur le Snapdragon 8 Elite devrait donner environ 15-25 tokens/s en CPU pour un modèle 4B en Q4, et potentiellement 25-35 tokens/s avec le backend Vulkan sur l'Adreno 830. ExecuTorch + QNN sur le NPU devrait pousser à 30-50 tokens/s avec une consommation batterie 3-4× inférieure. Le premier est plus simple à déployer, le second est meilleur pour une utilisation prolongée en milieu hospitalier. + +Dis à Damien de bien utiliser le flag `-ngl` pour l'offloading GPU Vulkan quand il teste, et de benchmarker avec `llama-bench` pour avoir des chiffres reproductibles. Tu veux que je lui prépare les commandes de compilation optimisées pour le Snapdragon 8 Elite ? + +--- + +You asked: +---------- + +non ce n'est pas Damien, le developpeur est une session claude code + +Mar 24 + +--- + +Claude Replied: +--------------- + +Recalibrated collaboration strategy with Claude Code instance + +Recalibrated collaboration strategy with Claude Code instance + +Ah, c'est une session Claude Code qui est en train de préparer llama.cpp sur ta machine connectée à la tablette ! C'est exactement le workflow qu'on décrivait — Claude Code qui compile, pousse via ADB, et teste directement sur le Snapdragon 8 Elite. + +Du coup on a une répartition naturelle des rôles. La session Claude Code travaille directement sur ta machine : elle compile, déploie, exécute, debug en temps réel sur la tablette. Moi ici sur Claude Desktop, je fais l'architecture, la recherche, les décisions techniques, la stratégie. C'est complémentaire. + +**Ce qui serait très utile maintenant :** quand la session Claude Code aura llama.cpp fonctionnel sur la tablette, récupère les métriques et partage-les moi ici. J'ai besoin de savoir concrètement pour ton Snapdragon 8 Elite : + +Les tokens/seconde en decode pour différentes tailles de modèle (si possible un 1.5-2B et un 4B en Q4\_0). Le temps de chargement du modèle (cold start). La mémoire RAM réellement consommée (pas théorique). Et surtout si le test est fait en CPU pur, avec Vulkan (GPU Adreno 830), ou les deux. + +Ces chiffres réels détermineront si llama.cpp suffit pour le MVP ou si l'effort d'intégration ExecuTorch + QNN pour le NPU est nécessaire dès le départ. Si llama.cpp donne déjà 30+ tokens/s en Vulkan sur un modèle 4B, c'est peut-être suffisant pour commencer et on garde ExecuTorch pour une phase d'optimisation ultérieure. Si c'est en dessous de 20 tokens/s, le NPU devient prioritaire. + +En attendant ces résultats, tu veux qu'on avance sur un autre sujet — par exemple préparer le récapitulatif complet de l'architecture, ou travailler sur la structure du prompt optimisé pour Qwen3 ? + +--- + +You asked: +---------- + +le constat est que sous forte charge le CPU fait chauffer la tablette, il faudra vraiment privilegier le NPU + +Mar 24 + +--- + +Claude Replied: +--------------- + +Validé la nécessité de privilégier le NPU pour éviter surchauffe + +Validé la nécessité de privilégier le NPU pour éviter surchauffe + +C'est exactement ce qu'on anticipait — et c'est un résultat important. Le CPU Oryon du Snapdragon 8 Elite monte à 4.32 GHz, il chauffe forcément sous charge soutenue d'inférence LLM. Rappelle-toi les benchmarks qu'on avait trouvés : le CPU throttle après 3 minutes à 42°C, alors que le NPU maintient des performances stables pendant 10+ minutes à 36-38°C avec 3.7× plus de tokens par watt. + +Pour un chatbot thérapeutique où les sessions durent 20-30 minutes, une tablette qui chauffe dans les mains du patient, c'est rédhibitoire. Et le throttling thermique du CPU signifie que les réponses ralentissent progressivement au fil de la conversation — exactement le contraire de ce qu'on veut. + +**Ça tranche le débat : ExecuTorch + QNN delegate sur le NPU Hexagon n'est pas une optimisation future, c'est un prérequis.** llama.cpp reste utile pour le prototypage rapide et le debug, mais pas pour la production. + +Dis à ta session Claude Code de mettre en pause l'optimisation llama.cpp CPU/GPU et de basculer sur la préparation de l'environnement ExecuTorch + QNN. Les premières étapes concrètes seraient d'installer le QNN SDK depuis Qualcomm (il faut un compte développeur gratuit sur qpm.qualcomm.com), de cloner le repo ExecuTorch et de builder le backend Qualcomm avec le script `./backends/qualcomm/scripts/build.sh`, puis de tester avec un modèle simple (le DeepLab v3 de l'exemple officiel) pour valider que le NPU de votre OnePlus Pad 3 est bien accessible via QNN. Une fois ce pipeline validé, on pourra exporter votre Qwen3 en `.pte` et le faire tourner sur le NPU. + +Le risque principal à vérifier rapidement : certains devices ont des problèmes de détection du SoC par QNN. On a vu dans la recherche qu'un device avec le SM8750 retournait "No Snapdragon SOC detected". Il faut confirmer que le OnePlus Pad 3 est bien reconnu. La commande `adb shell cat /sys/devices/soc0/soc_id` donnera l'ID du SoC, et il faut vérifier qu'il correspond à ce qu'ExecuTorch attend pour le SM8750. + +Tu veux que je prépare les instructions détaillées étape par étape pour que ta session Claude Code setup ExecuTorch + QNN sur ta machine ? + +--- + +Claude Replied: +--------------- + +### BENCHMARK\_RAPPORT.md + +285 lines + +md + +Mar 25 + +--- + +Claude Replied: +--------------- + +Analyzed benchmark report and synthesized architectural implications + +Analyzed benchmark report and synthesized architectural implications + +Excellent rapport, très complet. Les résultats sont révélateurs et confirment certaines de nos hypothèses tout en apportant des surprises importantes. Laisse-moi te donner mon analyse. + +**Les bonnes nouvelles :** + +Le NPU fonctionne et Qwen3-4B tourne à **~20 tok/s** dessus. C'est suffisant pour une conversation thérapeutique fluide — environ 1.5 mot par seconde en français, ce qui est un débit de parole naturel quand c'est streamé vers le TTS. Et surtout, la RAM consommée est remarquablement faible : **344 Mo de shared buffers** pour un modèle 4B. C'est bien en dessous de nos estimations de 1.5 Go. Ça libère énormément de marge pour les autres composants. + +Le CPU est aussi un backup viable à **16 tok/s** — pas optimal à cause du throttling thermique (chute à 5-7 tok/s après quelques minutes à 55°C), mais suffisant pour du debug et du développement. + +**Les mauvaises nouvelles :** + +Le GPU Vulkan est **complètement mort** pour l'inférence LLM. Les crashes sont des erreurs driver, pas des bugs llama.cpp — le driver Vulkan compute de Qualcomm n'est tout simplement pas fait pour ça. On raye définitivement cette option. + +ExecuTorch + QNN a échoué à cause d'incompatibilités de versions (GCC 15, Python 3.14). C'est contournable mais ça confirme que l'écosystème Qualcomm est fragile côté toolchain. + +**Le point crucial du rapport : c'est le Genie SDK qui a gagné, pas ExecuTorch.** + +C'est un changement important par rapport à notre architecture planifiée. Le chemin qui a fonctionné c'est : **modèle pré-compilé Qualcomm AI Hub → Genie SDK → NPU**. Pas ExecuTorch. Et le Genie SDK a une contrainte majeure : il ne supporte nativement que les architectures LLaMA pour la conversion. Ton Qwen3-4B a fonctionné parce que Qualcomm l'avait **pré-compilé** sur AI Hub. Mais ton Qwen3 fine-tuné pour Kazeia (avec tes données thérapeutiques) n'est pas sur AI Hub. + +**Ça soulève une question stratégique fondamentale pour Kazeia :** + +Tu as deux chemins possibles maintenant. Le premier serait de **fine-tuner un modèle déjà disponible sur AI Hub** plutôt que de partir de zéro. Qualcomm a pré-compilé Qwen3-4B, Llama 3.2 3B, et d'autres. Si tu fine-tunes un Qwen3-4B en thérapeutique et que Qualcomm le recompile (via AI Hub Workbench, leur service cloud), tu retombes sur le même chemin qui fonctionne. Le problème c'est la dépendance à Qualcomm pour chaque itération de ton modèle. + +Le deuxième chemin serait de **faire fonctionner ExecuTorch + QNN** en résolvant les problèmes de toolchain. Le rapport dit que ça a échoué à cause de GCC 15 et Python 3.14 — ce sont des versions trop récentes. ExecuTorch est vérifié avec Python 3.10 et GCC 11-13. Ta session Claude Code pourrait retenter avec un environnement contrôlé (Docker Ubuntu 22.04 avec les bonnes versions). C'est le chemin qui te donne l'autonomie complète pour exporter tes propres modèles fine-tunés. + +**Ce que je recommande :** + +Court terme, utilise le Qwen3-4B pré-compilé qui fonctionne déjà à 20 tok/s sur le NPU. C'est un modèle Qwen3 standard, pas fine-tuné pour la thérapie, mais il est multilingue (français inclus) et tu peux compenser avec un prompt système bien conçu et le RAG. C'est ton MVP — un chatbot empathique qui tourne sur le NPU, avec le prompt optimisé qu'on a défini, les stopping criteria, et le KV cache. Ça te permet de tester tout le reste du pipeline (VAD, STT, TTS, interface) pendant que tu travailles en parallèle sur le fine-tuning. + +En parallèle, fais retenter ExecuTorch + QNN à ta session Claude Code dans un container Docker Ubuntu 22.04 avec Python 3.10 et GCC 11. C'est l'environnement exact documenté par ExecuTorch. Si ça fonctionne, tu auras la capacité d'exporter ton propre Qwen3 fine-tuné directement en `.pte` pour le NPU, sans dépendre de Qualcomm AI Hub. + +Et il y a aussi une troisième option intermédiaire intéressante : le rapport mentionne le **transformer-composer** de Qualcomm. Vérifie si tu peux soumettre ton modèle fine-tuné directement à AI Hub Workbench pour compilation. C'est un service cloud gratuit qui compile ton modèle PyTorch en context binaries QNN. Si ça marche, tu n'as besoin d'ExecuTorch en local que pour le développement — la compilation production passe par le cloud Qualcomm. + +**L'autre point important du rapport : l'écart avec les benchmarks officiels.** + +Qualcomm annonce 29 tok/s, vous mesurez 20 tok/s. Le rapport suggère d'optimiser la config Genie (`perf_profile`, `cpu-mask`). C'est 30% de marge récupérable. Dis à ta session Claude Code d'essayer `"perf_profile": "burst"` et `"cpu-mask": "performance"` dans le `genie_config.json`. Ça force les cœurs performance à assister le NPU pour le scheduling et devrait rapprocher des 29 tok/s. + +Tu veux qu'on prépare le prompt système optimisé pour le Qwen3-4B standard (non fine-tuné) pour commencer à tester les interactions thérapeutiques dès maintenant, ou qu'on se concentre d'abord sur le plan pour débloquer ExecuTorch ? + +--- + +You asked: +---------- + +quelles sont les différences entre le Génie SDK et ExecuTorch + QNN + +Mar 25 + +--- + +Claude Replied: +--------------- + +Orchestrated structured comparison of two SDK architectures + +Orchestrated structured comparison of two SDK architectures + +Très bonne question — c'est le choix architectural clé après ces benchmarks. Les deux passent par le même NPU Hexagon, mais l'approche est radicalement différente. + +**En une phrase : Genie SDK est un produit fini verrouillé par Qualcomm, ExecuTorch + QNN est une boîte à outils ouverte avec laquelle tu construis toi-même.** + +**Sur le pipeline de conversion du modèle :** + +Avec Genie SDK, le chemin est : soit tu prends un modèle pré-compilé depuis Qualcomm AI Hub (c'est ce qui a fonctionné dans tes benchmarks), soit tu soumets ton modèle au cloud AI Hub Workbench qui le compile pour toi. Dans les deux cas, tu reçois des **context binaries** — des blobs binaires opaques compilés spécifiquement pour ton chipset (SM8750). Tu ne peux pas les modifier, les inspecter, ni les recompiler toi-même en local. Chaque itération de ton modèle (nouveau fine-tuning, changement d'architecture) nécessite une recompilation via les outils Qualcomm. + +Avec ExecuTorch + QNN, tu fais tout en local sur ta machine. Tu pars de ton modèle PyTorch, tu appelles `torch.export()`, tu appliques la quantization avec torchao, tu partitionnes avec le `QnnPartitioner`, et tu obtiens un fichier `.pte`. Tout est reproductible, scriptable, et tu peux itérer en boucle sans dépendance cloud. Le `.pte` contient à la fois les parties NPU (déléguées à QNN) et les parties fallback CPU — c'est un seul fichier autosuffisant. + +**Sur les modèles supportés :** + +C'est la différence la plus impactante pour Kazeia. Genie SDK ne supporte nativement que les architectures de type LLaMA pour la conversion automatique. Ton benchmark le confirme : la tentative de convertir Qwen 2.5 3B manuellement a échoué car `QnnGenAiTransformer` ne supporte que LLaMA. Les modèles Qwen3 qui fonctionnent sur AI Hub ont été compilés par Qualcomm eux-mêmes avec des outils internes que tu n'as pas. Si demain tu fine-tunes un Qwen3-4B sur des données thérapeutiques françaises, tu ne pourras pas le recompiler via Genie SDK seul — il te faudra le soumettre à AI Hub Workbench et espérer qu'il soit accepté et compilé correctement. + +ExecuTorch supporte toute architecture PyTorch exportable via `torch.export()`. Qwen3 est explicitement listé parmi les modèles supportés dans la dernière release. Tu fine-tunes ton Qwen3, tu l'exportes, tu le quantizes, tu le déploies. Pas de dépendance à un catalogue pré-compilé. + +**Sur le runtime Android :** + +Genie SDK fournit un runner C++ (`genie-t2t-run`) que tu appelles via ligne de commande ou que tu intègres via JNI. C'est un exécutable autonome qui gère le chargement des context binaries, le KV cache, la tokenization, et la génération. C'est simple à utiliser mais opaque — tu ne contrôles pas la gestion mémoire interne, le scheduling des tokens, ni le streaming granulaire. Le rapport montre que l'initialisation prend ~10 secondes pour charger 4 context binaries. + +ExecuTorch fournit une bibliothèque AAR Maven pour Android avec des APIs Java/Kotlin. Tu as un contrôle fin sur chaque étape : chargement du modèle (avec mmap), gestion du KV cache, callback token par token pour le streaming, gestion mémoire explicite. Tu peux implémenter le streaming phrase par phrase vers le TTS, les stopping criteria custom, la température adaptative — tout ce qu'on a décrit dans l'architecture Kazeia. Avec Genie, tu es plus limité dans la finesse du contrôle. + +**Sur la performance :** + +Les deux utilisent le même NPU Hexagon via la même couche QNN. En théorie, les performances brutes devraient être identiques. En pratique, Qualcomm optimise ses context binaries avec des techniques internes (fusion de couches, scheduling optimisé) qui peuvent donner un léger avantage à Genie. Le benchmark Qualcomm officiel de 29 tok/s pour Qwen3-4B est probablement mesuré avec Genie et une config optimale. ExecuTorch pourrait être légèrement en dessous car le partitioning automatique est moins agressif que la compilation manuelle de Qualcomm. L'écart est typiquement de 10-20%. + +**Sur la maturité et la stabilité :** + +Ton benchmark le montre clairement. Genie SDK avec des modèles pré-compilés : ça fonctionne du premier coup (enfin, au 4ème essai, mais le succès est reproductible). ExecuTorch + QNN : a échoué à cause de problèmes de toolchain (GCC 15, Python 3.14). ExecuTorch est en v1.0-1.1, c'est encore jeune. Le QNN delegate est activement développé mais les incompatibilités de versions sont fréquentes. Le rapport est très représentatif de l'expérience réelle des développeurs avec ExecuTorch/QNN aujourd'hui. + +**Sur l'autonomie à long terme :** + +C'est le point décisif. Avec Genie SDK tu dépends de Qualcomm pour chaque modèle, chaque mise à jour, chaque nouveau chipset. Si Qualcomm ne pré-compile pas ton modèle fine-tuné, tu es bloqué. Si Qualcomm arrête de supporter le SM8750 dans AI Hub, tu perds la capacité de recompiler. Avec ExecuTorch tu es souverain — tu peux recompiler tes modèles pour n'importe quel chipset supporté, à n'importe quel moment, sans demander la permission à personne. + +**Voici le résumé :** + + Genie SDK ExecuTorch + QNN + ──────────────────────────────────────────────────────────────── + Modèles supportés Catalogue AI Hub Tout PyTorch exportable + Modèle custom fine-tuné Via AI Hub Workbench En local, autonome + Format de sortie Context binaries .pte (portable) + (opaques, verrouillés) (inspectable) + Pipeline de conversion Cloud Qualcomm Local, scriptable + Contrôle runtime Limité (runner C++) Fin (APIs Java/Kotlin) + Streaming token/token Basique Complet avec callbacks + Performance NPU Optimale (29 tok/s) Légèrement inférieure + (~20-25 tok/s estimé) + Stabilité aujourd'hui Bonne (si pré-compilé) Fragile (toolchain) + Autonomie Dépendant Qualcomm Indépendant + Setup initial Simple Complexe + ──────────────────────────────────────────────────────────────── + +**Ma recommandation pour Kazeia :** + +Utilise les deux, dans cet ordre. Maintenant, pour le MVP, exploite le Qwen3-4B pré-compilé via Genie SDK — ça marche, c'est validé, 20 tok/s sur le NPU. En parallèle, fais résoudre le setup ExecuTorch + QNN dans un environnement propre (Docker Ubuntu 22.04, Python 3.10, GCC 11). Quand ExecuTorch fonctionne, tu auras la capacité d'exporter ton Qwen3 fine-tuné thérapeutique directement. À terme, ExecuTorch est le bon choix pour Kazeia parce que l'autonomie sur les modèles custom est non-négociable pour un dispositif médical. Mais en attendant, Genie te permet d'avancer sur tout le reste du projet sans être bloqué. + +Tu veux qu'on avance sur l'intégration du Genie runner dans l'application Android Kotlin, ou qu'on prépare le plan pour débloquer ExecuTorch ? + +--- + +Claude Replied: +--------------- + +### BENCHMARK\_RAPPORT.md + +336 lines + +md + +Mar 25 + +--- + +Claude Replied: +--------------- + +I cannot provide a summary because the user prompt is empty. Please provide a user prompt so I can determine the primary language and create an appropriate summary + +I cannot provide a summary because the user prompt is empty. Please provide a user prompt so I can determine the primary language and create an appropriate summary + +Ce deuxième rapport est une mine d'or — la session Claude Code a fait un travail remarquable. Et les résultats changent significativement la donne. Voici mon analyse. + +**La grande nouvelle : ExecuTorch + QNN fonctionne.** + +Le pipeline complet a été validé : export PyTorch → quantification INT4 → compilation QNN HTP v79 → sérialisation `.pte` → déploiement sur la tablette → inférence sur le NPU. Qwen3-0.6B tourne à **69.3 tok/s** sur le NPU via ExecuTorch. C'est une excellente nouvelle parce que ça prouve que le chemin autonome (sans dépendance aux modèles pré-compilés AI Hub) est viable. Vous pouvez exporter vos propres modèles fine-tunés. + +**Mais il y a un goulot d'étranglement critique : la RAM de compilation.** + +C'est le résultat le plus important du rapport et celui qu'on n'avait pas anticipé : + + Qwen3-0.6B → 17-20 Go RAM → OK sur votre PC (54 Go) + Qwen3-1.7B → 48-64 Go RAM → ÉCHEC (OOM sur 54 Go) + Qwen3-4B → 113-130 Go RAM → Nécessite serveur cloud + +Ça signifie que votre PC de développement (54 Go) ne peut exporter que des modèles jusqu'à environ 1B paramètres via ExecuTorch. Pour le Qwen3-4B qui tourne déjà via Genie SDK, l'export ExecuTorch nécessiterait un serveur avec 128+ Go de RAM. C'est faisable (une instance cloud c2-standard-60 sur GCP avec 240 Go de RAM coûte environ 2-3€/heure, l'export prendrait quelques heures), mais c'est une contrainte opérationnelle à intégrer dans votre workflow. + +**L'autre découverte majeure : sur les petits modèles, NPU ≈ CPU.** + +Qwen3-0.6B fait 69.3 tok/s sur le NPU et 68.2 tok/s sur le CPU. Quasi identique. C'est parce que les deux sont **limités par la bande passante mémoire** (77 Go/s LPDDR5X), pas par la puissance de calcul. Un modèle de 600 Mo tient presque entièrement dans les caches, donc ni le NPU ni le CPU ne sont contraints par le compute — ils attendent tous les deux la mémoire. L'avantage du NPU ne se manifeste vraiment qu'avec les modèles plus gros (4B : +27%) et surtout sur l'efficacité énergétique et la stabilité thermique. + +**Ce que ça implique pour la stratégie Kazeia :** + +La question du choix de modèle est maintenant liée à la question du framework : + + Qwen3-0.6B (fine-tuné Kazeia) + ├── Export ExecuTorch : faisable sur votre PC (54 Go) + ├── Performance : 69 tok/s (NPU ou CPU, peu importe) + ├── RAM tablette : 694 Mo + ├── Qualité empathique : limitée (très petit modèle) + └── Avantage : autonomie complète, itération rapide + + Qwen3-4B (pré-compilé AI Hub ou fine-tuné) + ├── Export ExecuTorch : nécessite serveur 128+ Go + ├── Via Genie pré-compilé : fonctionne immédiatement + ├── Performance : ~20 tok/s (NPU uniquement) + ├── RAM tablette : 344 Mo (Genie) — étonnamment peu + ├── Qualité empathique : bien meilleure + └── Contrainte : dépendance AI Hub OU serveur cloud + + Qwen3-1.7B (compromis potentiel) + ├── Export ExecuTorch : nécessite ~64 Go RAM (juste au-dessus) + ├── Pas de modèle pré-compilé AI Hub + ├── Performance estimée : ~35-45 tok/s (NPU) + ├── Qualité empathique : intermédiaire + └── Option : upgrader le PC à 64-128 Go ou serveur cloud + +**Ma recommandation mise à jour :** + +L'approche la plus pragmatique est un développement en **deux voies parallèles** : + +Pour le MVP immédiat, utilise le **Qwen3-4B pré-compilé via Genie SDK** à 20 tok/s. C'est déjà sur la tablette, ça fonctionne, la qualité du modèle 4B en français est suffisante pour tester tout le reste du pipeline (prompt système, RAG, stopping criteria, intégration Android). Tu ne perds pas de temps. + +Pour le fine-tuning thérapeutique, fine-tune un **Qwen3-0.6B** sur tes données empathiques françaises et exporte-le via ExecuTorch directement depuis votre PC. Ça tourne à 69 tok/s et ça te donne un cycle d'itération rapide : fine-tune → export → test sur tablette → ajuste → recommence. La question est de savoir si un 0.6B fine-tuné spécifiquement pour la thérapie peut rivaliser en qualité avec un 4B généraliste. C'est exactement le test à faire — et la littérature sur la distillation de connaissances montre que c'est souvent le cas pour un domaine étroit. + +Quand le modèle fine-tuné est validé et que tu veux passer en production avec un modèle plus gros, loue quelques heures de serveur cloud pour compiler le Qwen3-4B fine-tuné via ExecuTorch. C'est un coût ponctuel de quelques euros à chaque nouvelle version du modèle. + +**Les prochaines actions concrètes, dans l'ordre :** + +Premièrement, configurer le stop token et le system prompt Kazeia sur le Qwen3-4B Genie qui tourne déjà. C'est quelques lignes dans le `genie_config.json` et ça te donne immédiatement un prototype conversationnel testable. + +Deuxièmement, tester l'optimisation Genie (`perf_profile`, `cpu-mask`) pour récupérer les 30% de marge entre vos 20 tok/s et les 29 tok/s de Qualcomm. + +Troisièmement, commencer le fine-tuning d'un Qwen3-0.6B thérapeutique sur votre PC, avec le corpus synthétique qu'on a défini (distillation depuis un gros modèle, curriculum learning, DPO). Exporter via ExecuTorch et comparer la qualité empathique avec le 4B généraliste. + +Tu veux qu'on commence par rédiger le prompt système optimisé pour le Qwen3-4B Genie, ou qu'on prépare le plan de fine-tuning du Qwen3-0.6B ? + +--- + +You asked: +---------- + +nous allons utiliser chatterbox pour le tts, il y a un modele whisper préparé par qualcomm pour le stt, maintenant je me demande comment se passe l'implémentation de vAD pour detecter automatiquement la voix sans avoir à faire de manipulation + +Mar 26 + +--- + +Claude Replied: +--------------- + +Investigated VAD integration for hands-free voice detection pipeline + +Investigated VAD integration for hands-free voice detection pipeline + +Parfait — Chatterbox pour le TTS et Whisper Qualcomm pour le STT, bons choix. Le VAD est la pièce qui relie tout. Laisse-moi vérifier les options d'implémentation concrètes sur Android. + +Orchestrated practical VAD implementation guidance for hands-free voice detection + +Orchestrated practical VAD implementation guidance for hands-free voice detection + +Le VAD est en fait le composant le plus simple à intégrer de tout le pipeline Kazeia. Voici concrètement comment ça fonctionne. + +**Le principe : le micro est toujours ouvert, mais Whisper ne se déclenche que quand il y a de la parole.** + +Sans VAD, tu aurais deux options : soit le patient appuie sur un bouton pour parler (fastidieux, pas naturel), soit tu envoies en permanence le flux audio au STT Whisper (gaspillage de ressources NPU/CPU, et Whisper hallucine sur le silence — il génère des phrases fantômes comme "Merci d'avoir écouté" quand il n'y a que du bruit). C'est d'ailleurs exactement pour cette raison que SileroVAD a été intégré dans faster-whisper : pour filtrer les segments silencieux avant la transcription [Medium](https://medium.com/axinc-ai/silerovad-machine-learning-model-to-detect-speech-segments-e99722c0dd41). + +Le VAD résout ça. C'est un réseau de neurones léger basé sur ONNX Runtime Mobile, qui traite des chunks audio de quelques millisecondes et retourne une probabilité que le chunk contienne de la parole [GitHub](https://github.com/gkonovalov/android-vad). Le modèle fait 1.8 Mo et traite un chunk de 30ms en environ 1ms [Medium](https://medium.com/axinc-ai/silerovad-machine-learning-model-to-detect-speech-segments-e99722c0dd41). C'est négligeable en termes de ressources. + +**L'intégration Android est déjà prête.** Il existe une bibliothèque Android native en Kotlin (`android-vad`) qui supporte Silero VAD, WebRTC VAD et YAMNet VAD [GitHub](https://github.com/gkonovalov/android-vad). Le projet est entièrement en Kotlin, utilise ONNX Runtime, et est compatible avec les tablettes Android 15+ avec le support des pages 16 Ko [GitHub](https://github.com/gkonovalov/android-vad/releases). Tu l'ajoutes avec une seule ligne dans ton `build.gradle` : + +gradle + + implementation 'com.github.gkonovalov.android-vad:silero:1.0.2' + +**Le flux complet sans aucun bouton :** + + ┌─────────────────────────────────────────────────────┐ + │ AudioRecord Android (micro toujours ouvert) │ + │ Format: 16-bit Mono PCM, 16 kHz │ + │ Tourne dans un thread dédié du Foreground Service │ + └──────────────┬──────────────────────────────────────┘ + │ chunks de 512 samples (32ms) + ▼ + ┌─────────────────────────────────────────────────────┐ + │ Silero VAD (ONNX Runtime, 1.8 Mo, <1ms/chunk) │ + │ │ + │ Chaque chunk → probabilité de parole (0.0 à 1.0) │ + │ │ + │ Trois événements possibles : │ + │ ├── Silence continu → on ne fait rien │ + │ ├── Début de parole détecté → on commence à │ + │ │ accumuler l'audio dans un buffer │ + │ └── Fin de parole détectée (silence > 500ms après │ + │ de la parole) → on envoie le buffer à Whisper │ + └──────────────┬──────────────────────────────────────┘ + │ buffer audio complet d'une phrase + ▼ + ┌─────────────────────────────────────────────────────┐ + │ Whisper STT (Qualcomm, NPU) │ + │ Transcrit le segment de parole → texte │ + └──────────────┬──────────────────────────────────────┘ + │ texte + ▼ + Pipeline LLM Qwen3 → TTS Chatterbox → Avatar + +**Les paramètres clés de Silero VAD pour un contexte thérapeutique :** + +Tu configures `silenceDurationMs` (durée minimale de silence pour considérer que la parole est terminée) et `speechDurationMs` (durée minimale de parole pour déclencher la détection) [GitHub](https://github.com/gkonovalov/android-vad). Pour Kazeia, les valeurs optimales seraient différentes d'un assistant vocal classique : + +Le `speechDurationMs` devrait être à **150-200ms** (au lieu des 50ms par défaut). En contexte thérapeutique, tu veux éviter de déclencher sur un soupir, un raclement de gorge, ou un bruit accidentel. 150ms garantit qu'il y a une vraie intention de parler. + +Le `silenceDurationMs` devrait être à **800-1000ms** (au lieu des 300ms par défaut). Un patient en détresse fait des pauses longues entre les mots — il hésite, il cherche ses mots, il pleure entre deux phrases. Avec 300ms, le VAD découperait sa phrase en morceaux. Avec 800-1000ms, tu laisses le temps au patient de finir sa pensée complète avant de transcrire. + +Le mode devrait être **NORMAL** plutôt que AGGRESSIVE. Le mode agressif a un seuil de détection plus haut et risquerait de manquer une voix faible ou chuchotée — exactement le type de voix qu'un patient déprimé pourrait avoir. + +**La gestion de l'echo cancellation — le point critique :** + +Quand Chatterbox joue la réponse TTS via le haut-parleur, le micro capte cette voix et le VAD la détecte comme de la parole. Sans protection, ça crée une boucle. Il y a trois niveaux de solution : + +Le plus simple : **muter le VAD pendant la lecture TTS**. Quand le TTS commence à parler, tu désactives le traitement VAD. Quand le TTS finit, tu réactives. Inconvénient : le patient ne peut pas interrompre l'avatar. + +Mieux : utiliser le mode **`AudioManager.MODE_IN_COMMUNICATION`** d'Android qui active l'AEC (Acoustic Echo Cancellation) matériel du Snapdragon. Le micro filtre automatiquement le son qui sort du haut-parleur. Ça permet au patient d'interrompre l'avatar naturellement — exactement comme dans une vraie conversation thérapeutique. + +Encore mieux : combiner les deux. AEC matériel activé en permanence pour filtrer l'écho, plus un flag booléen `isAvatarSpeaking` qui **augmente le seuil de détection** du VAD pendant la lecture TTS (passer de 0.5 à 0.8 par exemple). Comme ça, seule une voix forte et claire du patient (une vraie interruption intentionnelle) est détectée pendant que l'avatar parle, mais les résidus d'écho qui passeraient l'AEC sont ignorés. + +**L'implémentation concrète en Kotlin serait :** + +kotlin + + class VoiceDetectionManager( + private val context: Context, + private val onSpeechSegment: (ShortArray) -> Unit // callback avec l'audio + ) { + private val vad = VadSilero( + context = context, + sampleRate = SampleRate.SAMPLE_RATE_16K, + frameSize = FrameSize.FRAME_SIZE_512, + mode = Mode.NORMAL, + silenceDurationMs = 800, // pauses longues tolérées + speechDurationMs = 150 // évite les faux positifs + ) + + private val audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, // AEC activé + 16000, CHANNEL_IN_MONO, ENCODING_PCM_16BIT, + 16000 * 2 // 1 seconde de buffer + ) + + private var isListening = true + private var isAvatarSpeaking = false + private val speechBuffer = mutableListOf() + private var isSpeechActive = false + + fun startListening() { + audioRecord.startRecording() + + thread(name = "VAD-Listener") { + val frame = ShortArray(512) + + while (isListening) { + audioRecord.read(frame, 0, 512) + + // Pendant que l'avatar parle, on augmente le seuil + val threshold = if (isAvatarSpeaking) 0.85 else 0.5 + val isSpeech = vad.isSpeech(frame) + + if (isSpeech) { + if (!isSpeechActive) { + isSpeechActive = true + speechBuffer.clear() + } + speechBuffer.add(frame.copyOf()) + } else if (isSpeechActive) { + // Le VAD a détecté la fin de parole + // (silence > 800ms après de la parole) + isSpeechActive = false + + // Concaténer tout le buffer et l'envoyer + val fullAudio = speechBuffer.flatten().toShortArray() + onSpeechSegment(fullAudio) + speechBuffer.clear() + } + } + } + } + + fun onAvatarStartSpeaking() { isAvatarSpeaking = true } + fun onAvatarStopSpeaking() { isAvatarSpeaking = false } + + fun stop() { + isListening = false + audioRecord.stop() + vad.close() + } + } + +Et dans ton Foreground Service Kazeia, l'intégration serait : + +kotlin + + val voiceDetector = VoiceDetectionManager(context) { audioSegment -> + // Le VAD a détecté une phrase complète + serviceScope.launch { + // 1. Transcrire via Whisper NPU + val text = whisperEngine.transcribe(audioSegment) + + // 2. Envoyer au pipeline LLM + processMessage(currentPatientId, text, onToken, onComplete) + } + } + + voiceDetector.startListening() + +C'est tout. Le patient parle, le VAD découpe, Whisper transcrit, le LLM répond, Chatterbox synthétise, l'avatar parle. Zéro bouton, interaction naturelle. + +**Le coût en ressources est dérisoire :** + + Silero VAD : 1.8 Mo modèle, <1ms par frame, 1 thread CPU efficacité + AudioRecord : ~32 Ko buffer circulaire + Buffer parole : ~100-500 Ko par segment (5-30 secondes de parole) + ──────────────────────────────────────────────────────────── + Impact total : ~2 Mo RAM, ~0.1% d'un cœur CPU + +C'est invisible par rapport au reste du pipeline. Tu veux que je prépare les instructions pour ta session Claude Code pour intégrer ça dans le projet Android, ou qu'on avance sur un autre composant ? + +--- + +You asked: +---------- + +vu que nous aurions très certainement un avatar 3d fait avec unity, qui aura du lipsync avec notre tts, quel serait le meilleur language pour le developpement de l'application + +Mar 26 + +--- + +Claude Replied: +--------------- + +Pesé arquitectura entre Unity nativo o Android como aplicación principal + +Pesé arquitectura entre Unity nativo o Android como aplicación principal + +C'est LA question architecturale qui va déterminer tout le reste du développement. Et la réponse n'est pas évidente parce que tu as deux mondes qui doivent cohabiter : Unity (C#) pour l'avatar et Android natif (Kotlin/C++) pour toute l'inférence IA. + +Tu as concrètement trois options. + +**Option 1 : Unity est l'application principale, tout en C#.** + +Unity gère l'intégralité de l'app — l'avatar 3D, l'interface patient, et tous les composants IA sont appelés depuis C# via des plugins natifs. Le Genie SDK, Whisper, Silero VAD, Chatterbox, SQLite — tout est wrappé dans des plugins Unity natifs (.so) appelés via P/Invoke ou des wrappers Java/JNI bridgés vers C#. + +L'avantage c'est un seul projet, un seul langage, une seule build pipeline. L'avatar et la logique métier vivent au même endroit, la communication entre le lip-sync et le TTS est directe, pas d'IPC. + +Le problème c'est que les Foreground Services Android, les permissions audio, l'AudioRecord pour le VAD, la gestion du cycle de vie Android — tout ça est natif Android et s'intègre mal depuis Unity. Tu te retrouves à écrire des plugins Java/Kotlin pour chaque interaction Android, puis des bridges C# pour les appeler. C'est une couche d'abstraction supplémentaire sur chaque composant. Et le debug devient pénible : quand le Genie SDK crashe dans le code natif, le stack trace remonte à travers JNI → Java → Unity bridge → C#, et c'est l'enfer à diagnostiquer. + +**Option 2 : Android natif (Kotlin) est l'application principale, Unity est embarqué comme vue.** + +L'app Kotlin gère tout : le Foreground Service avec les modèles en mémoire, le VAD, le STT, le LLM, le TTS, l'authentification, SQLite, la synchronisation serveur. Unity est intégré comme un **UnityPlayer** dans un Fragment ou une Activity dédiée, exclusivement pour le rendu de l'avatar 3D et le lip-sync. La communication entre Kotlin et Unity se fait via `UnityPlayer.UnitySendMessage()` (Kotlin → Unity) et des interfaces Android (`AndroidJavaObject` dans Unity → Kotlin). + +L'avantage c'est que toute la logique IA tourne dans l'écosystème natif où elle est conçue pour tourner. Le Genie SDK est appelé directement en C/C++ via JNI sans couche Unity intermédiaire. Les Services Android, les permissions, l'audio — tout est en Kotlin natif, propre et documenté. Le debug est standard Android. + +Le problème c'est la communication Unity ↔ Kotlin. Pour le lip-sync, tu dois streamer les données audio ou les visèmes de Kotlin vers Unity en temps réel. `UnitySendMessage` est asynchrone et ne passe que des strings — pas idéal pour du streaming audio. Pour des données binaires haute fréquence (visèmes à 30-60 fps), il faut un shared memory buffer ou un bridge JNI plus bas niveau. C'est faisable mais c'est du travail d'ingénierie. + +**Option 3 : Unity est l'application principale, Kotlin gère les services en arrière-plan.** + +C'est un hybride. L'Activity principale est Unity (avatar, interface, lip-sync). Mais tu crées un Foreground Service séparé en Kotlin pur qui gère tout le pipeline IA (LLM, STT, TTS, VAD). Les deux communiquent via un `Binder` local ou un `Messenger`. Unity envoie "le patient a dit X" au Service, le Service traite et renvoie "réponse = Y, audio = Z, émotion = W". Unity anime l'avatar en conséquence. + +L'avantage c'est que chaque composant est dans son environnement optimal. Unity fait ce qu'il fait le mieux (rendu 3D, animation), Kotlin fait ce qu'il fait le mieux (services Android, inférence IA native). Le Service survit indépendamment de l'Activity Unity — si Unity redémarre, les modèles restent en mémoire. + +Le problème c'est la complexité de l'architecture avec deux "cerveaux" qui doivent se synchroniser. Et la latence de communication inter-process si tu utilises un Service bound avec AIDL. + +**Ma recommandation pour Kazeia : Option 3, Unity principal + Service Kotlin.** + +Voici pourquoi. Pour un chatbot thérapeutique avec avatar, l'expérience visuelle EST l'application. Le patient voit l'avatar, interagit avec lui, reçoit les réponses visuellement et auditivement. Unity doit être le premier plan. Mais tout le pipeline IA (qui représente 80% de la complexité technique) n'a rien à faire dans Unity — c'est du code natif Android qui s'exécute en arrière-plan. + +L'architecture serait : + + ┌─────────────────────────────────────────────────────┐ + │ Unity Activity (C#) │ + │ ├── Avatar 3D (rendu, animation, lip-sync) │ + │ ├── Interface patient (chat texte, indicateurs) │ + │ ├── uLipSync (audio → blendshapes bouche) │ + │ ├── Expressions émotionnelles (blendshapes visage) │ + │ └── Communique avec le Service via AndroidJava │ + ├─────────────────────────────────────────────────────┤ + │ KazeiaService (Kotlin, Foreground Service) │ + │ ├── VoiceDetectionManager (Silero VAD + AudioRecord)│ + │ ├── WhisperEngine (Qualcomm NPU) │ + │ ├── LlmEngine (Genie SDK / ExecuTorch, NPU) │ + │ ├── TtsEngine (Chatterbox) │ + │ ├── EmotionClassifier (ONNX Runtime) │ + │ ├── RagManager (SQLite + sqlite-vec + embeddings) │ + │ ├── ConversationManager (machine à états, PHQ-9) │ + │ └── SessionManager (auth, profils, sync serveur) │ + └─────────────────────────────────────────────────────┘ + +La communication entre les deux serait un **Binder local** (in-process, pas d'IPC). Unity et le Service tournent dans le même processus Android, donc le Binder est un simple appel de méthode Java — zéro overhead de sérialisation. Côté Unity tu appelles : + +csharp + + // Unity (C#) → Kotlin Service + AndroidJavaClass serviceClass = new AndroidJavaClass("com.kazeia.service.KazeiaBridge"); + serviceClass.CallStatic("sendPatientMessage", messageText); + + // Kotlin Service → Unity (C#) : callback pour chaque token streamé + // Le Service appelle UnitySendMessage depuis Kotlin : + UnityPlayer.UnitySendMessage("KazeiaController", "OnLlmToken", token); + UnityPlayer.UnitySendMessage("KazeiaController", "OnEmotionDetected", "tristesse:0.82"); + +Pour le lip-sync spécifiquement, c'est plus efficace de ne pas passer l'audio complet de Unity au Service. Chatterbox génère l'audio dans le Service Kotlin, le Service joue l'audio via un `AudioTrack` partagé, et Unity reçoit soit les visèmes pré-calculés, soit l'audio brut via un shared buffer pour que uLipSync fasse son analyse en temps réel côté Unity. + +**Le langage final par composant :** + + Composant Langage Pourquoi + ───────────────────────────────────────────────────── + Avatar 3D + lip-sync C# (Unity) rendu, animation + Interface patient C# (Unity) UI intégrée à la scène 3D + Pipeline IA (Service) Kotlin APIs Android natives + Genie SDK / ExecuTorch C/C++ (JNI) performance NPU + Silero VAD Kotlin/ONNX bibliothèque Android native + Whisper STT C/C++ (JNI) binaire Qualcomm natif + Chatterbox TTS Kotlin/C++ selon l'implémentation + SQLite + sqlite-vec Kotlin SDK Android natif + Logique métier (PHQ-9, etc) Kotlin portage du Python existant + +Tu codes principalement en **Kotlin** (70% du code) et en **C#** (30% du code). Le C/C++ existe déjà dans les bibliothèques tierces, tu n'as pas à en écrire. + +Tu veux qu'on détaille l'architecture de communication Unity ↔ Service Kotlin pour le streaming LLM + lip-sync, ou qu'on avance sur un autre aspect ? + +--- + +You asked: +---------- + +dans un premier temps nous n'allons pas avoir l'avatar on se bornera a du texte, mais avec le pipeline sTT et tts + +Mar 26 + +--- + +Claude Replied: +--------------- + +Envisagé architecture simplifiée sans avatar, pipeline audio complet + +Envisagé architecture simplifiée sans avatar, pipeline audio complet + +Parfait — ça simplifie énormément. Sans Unity, c'est du **100% Kotlin natif** avec les bibliothèques C/C++ appelées via JNI. Un seul projet Android Studio, un seul langage, un seul build. C'est exactement le bon MVP pour valider le cœur de Kazeia. + +L'application serait une interface texte simple — comme un chat — mais le patient peut parler (VAD + Whisper) et entendre la réponse (Chatterbox). Le texte de la conversation s'affiche à l'écran dans les deux sens, que le patient tape ou parle. + +**L'architecture se résume à :** + + ┌─────────────────────────────────────────────────┐ + │ KazeiaActivity (Kotlin) │ + │ ├── RecyclerView (messages chat) │ + │ ├── EditText + bouton envoyer (input texte) │ + │ ├── Indicateur micro (écoute VAD active) │ + │ └── Indicateur état (écoute / réfléchit / parle)│ + ├─────────────────────────────────────────────────┤ + │ KazeiaService (Kotlin, Foreground Service) │ + │ ├── VoiceDetectionManager (Silero VAD) │ + │ │ └── AudioRecord 16kHz mono, toujours actif │ + │ ├── WhisperEngine (Qualcomm NPU) │ + │ ├── LlmEngine (Genie SDK, Qwen3-4B NPU) │ + │ ├── TtsEngine (Chatterbox) │ + │ ├── ConversationManager (états, PHQ-9) │ + │ └── SQLite (profils, historique, RAG) │ + └─────────────────────────────────────────────────┘ + +Le flux complet d'une interaction serait : le patient parle → le micro capte en continu → Silero VAD détecte la parole → à la fin de la phrase (silence 800ms), le buffer audio part à Whisper → Whisper transcrit → le texte s'affiche dans le chat côté patient → le texte part au pipeline LLM (émotion + RAG + Qwen3) → les tokens arrivent en streaming et s'affichent dans le chat côté Kazeia → en parallèle, chaque phrase complète part à Chatterbox pour la synthèse vocale → l'audio est joué via le haut-parleur. Le patient peut aussi taper du texte directement, auquel cas on saute l'étape VAD + Whisper et on envoie directement au LLM. + +L'interface afficherait trois états visuels simples : un indicateur "écoute" quand le VAD est actif et attend de la parole, un indicateur "réfléchit" quand le LLM génère, et un indicateur "parle" quand Chatterbox joue l'audio. C'est minimal mais suffisant pour que le patient comprenne ce qui se passe. + +C'est le projet idéal pour ta session Claude Code — un projet Android Studio Kotlin avec Gradle, les dépendances natives (Genie SDK, Silero VAD, Chatterbox), et un déploiement direct sur la tablette OnePlus Pad 3 via ADB. + +Tu veux que je prépare la structure complète du projet (arborescence, dépendances Gradle, classes principales) pour que ta session Claude Code puisse démarrer l'implémentation ? + +--- + +You asked: +---------- + +pour le moment nous allons démarrer sur un modèle plus simple avec un simple modèle sans rag, mais avec stt, tts et vad, une interface simple, avec l'ecriture des phrases + +Mar 26 + +--- + +Claude Replied: +--------------- + +Orchestrated minimal stack architecture pour démarrage simplifié + +Orchestrated minimal stack architecture pour démarrage simplifié + +Parfait, on va au plus simple. LLM + STT + TTS + VAD + interface chat. Pas de RAG, pas de classifieur d'émotion, pas de PHQ-9. On valide que le pipeline vocal fonctionne de bout en bout sur la tablette. + +Tu veux que je te prépare la structure du projet Android Studio avec l'arborescence des fichiers, les dépendances Gradle, et les classes principales pour que ta session Claude Code puisse démarrer directement l'implémentation ? + +--- + +You asked: +---------- + +ce qui serait bon c'est d'avoir une structure modulaire pour plugger facilement une structure autre à la place du llm par la suite, nous devons préparer tout ce qui se passe autour + +Mar 26 + +--- + +Claude Replied: +--------------- + +Architected modular design pour swapper facilement le composant LLM + +Architected modular design pour swapper facilement le composant LLM + +Exactement la bonne approche — construire le chassis avec des interfaces propres, le moteur LLM se branche dessus. Laisse-moi te préparer ça. + +Orchestrated modular architecture pour flexibilité future + +Orchestrated modular architecture pour flexibilité future + +Voici l'architecture complète. Le principe central c'est que **chaque composant est une interface Kotlin** avec des implémentations interchangeables. Le LLM, le STT, le TTS et le VAD sont tous des contrats — tu peux brancher n'importe quelle implémentation sans toucher au reste. + +**Les décisions clés :** + +Le **Foreground Service** est le cœur. C'est lui qui garde tous les modèles en mémoire et orchestre le pipeline VAD → STT → LLM → TTS. L'Activity (l'interface chat) ne fait qu'observer des `StateFlow` et afficher les résultats. Si l'Activity est détruite (écran de veille, changement de session), le Service continue de tourner et les modèles restent chauds. + +Les **interfaces dans `core/`** sont volontairement minimalistes. `LlmEngine` a trois méthodes : `load()`, `generate()` avec streaming via callback, et `release()`. Pour passer du Genie SDK à ExecuTorch ou llama.cpp, tu changes une seule ligne d'instanciation dans le Service. Pareil pour le STT (Whisper Qualcomm ↔ SpeechRecognizer natif) et le TTS (Chatterbox ↔ TTS natif Android). + +Le pipeline audio est séparé en deux managers : `AudioCaptureManager` gère le micro et le VAD en continu, `AudioPlaybackManager` gère la sortie TTS. L'`EchoCancellationManager` coordonne les deux pour éviter que le VAD ne détecte la voix du TTS. + +L'**ordre d'implémentation** est pensé pour que ta session Claude Code puisse valider chaque brique indépendamment : d'abord l'UI seule, puis le LLM texte, puis le VAD + micro, puis le STT, puis le TTS, et enfin le pipeline complet sans bouton. À chaque étape tu as quelque chose de testable sur la tablette. + +Tu peux passer ça directement à ta session Claude Code pour qu'elle démarre l'implémentation. Tu veux qu'on ajuste quelque chose dans l'architecture avant ? + +Kazeia architecture + +Document · MD  + +Download + +--- + +You asked: +---------- + +### BENCHMARK\_RAPPORT.md + +md + +### AI\_HUB\_QUALCOMM.md + +3 lines + +md + +### ARCHITECTURE\_PIPELINE.md + +194 lines + +md + +### BENCHMARK\_ROOT\_VS\_NONROOT.md + +201 lines + +md + +### DEPLOY\_EXECUTORCH\_NPU.md + +139 lines + +md + +### GUIDE\_ROOT\_ONEPLUS\_PAD3.md + +304 lines + +md + +### RAPPORT\_TTS.md + +254 lines + +md + +Nous avons travaillé avec le developpeur sur l'adaptation à la tablette Oneplus Pad3, voici les fichiers en rapport + +Mar 29 + +--- + +Claude Replied: +--------------- + +Analysé progrès significatifs du développement tablet avec ExecuTorch et NPU + +Analysé progrès significatifs du développement tablet avec ExecuTorch et NPU + +C'est un travail remarquable. La session Claude Code a accompli énormément en quelques jours. Laisse-moi faire le point sur où vous en êtes par rapport à ce qu'on avait planifié. + +**Les avancées majeures que je retiens :** + +Premièrement, le **root est indispensable** pour le NPU. Sans root, pas d'accès au DSP Hexagon, pas de LLM, pas de pipeline IA. C'est une contrainte de déploiement qu'on n'avait pas anticipée — chaque tablette hospitalière devra être rootée. C'est gérable mais ça complexifie la maintenance et les mises à jour OTA. + +Deuxièmement, **ExecuTorch a finalement gagné sur Genie SDK**. Le document d'architecture montre que Genie est marqué "abandonné". C'est logique — ExecuTorch vous donne l'autonomie complète sur vos modèles custom, et les performances sont excellentes. Qwen3-0.6B fait **93 tok/s** et Qwen3-1.7B fait **46 tok/s** sur le NPU. C'est bien au-dessus de nos estimations initiales. + +Troisièmement, et c'est la plus grosse surprise : **vous avez réussi l'export du talker Qwen3-TTS sur le NPU**. C'est exactement ce qu'on avait discuté comme étant "3-5 semaines de travail avec un risque d'échec" — et la session Claude Code l'a fait. Le talker tourne à **90.7 tok/s** sur le NPU, ce qui transforme un pipeline 5× trop lent en CPU en un pipeline **quasi temps réel** (~1.9s pour 2.3s d'audio). C'est un résultat exceptionnel. + +Quatrièmement, l'**architecture a évolué vers un pattern processor chain** qui est plus flexible que ce qu'on avait prévu. Au lieu d'un pipeline linéaire rigide, les processeurs sont chaînés et le premier qui retourne `shouldContinueChain = false` termine la chaîne. C'est élégant pour ajouter le RAG, le classifieur d'émotion, la diarisation — chacun est un processeur indépendant qu'on branche dans la chaîne. + +**Les points qui restent à résoudre :** + +Le **STT Whisper est encore sur CPU à 1500ms**. Le document mentionne qu'un `qnn_whisper_runner` est compilé mais pas encore intégré. C'est le goulot d'étranglement actuel du pipeline — l'estimation est de ~50ms sur NPU, ce qui diviserait la latence totale par presque deux. + +**Chatterbox ne fonctionne pas de manière fiable** — OOM à 1.4 Go et les versions quantifiées ne produisent pas le stop token. Mais avec Qwen3-TTS talker sur le NPU à 90.7 tok/s, Chatterbox n'est plus nécessaire. Le pipeline TTS Qwen3-TTS est supérieur en tout point, avec en plus le clonage vocal. + +Le **pipeline TTS complet n'est pas encore assemblé sur la tablette**. Le talker tourne sur le NPU, mais le code predictor (141M, 5 couches), le speaker encoder (8.9M) et le speech decoder (114M, ConvNet) doivent encore tourner sur CPU. L'orchestration via Termux + Python est un workaround — à terme il faudra intégrer tout ça en natif dans l'app Kotlin. + +**Le budget mémoire réel, basé sur vos mesures :** + + Composant RAM mesurée + ────────────────────────────────────────────── + Qwen3-1.7B LLM (.pte, NPU) 1 712 Mo + Qwen3-TTS talker (.pte, NPU) 286 Mo (+ poids CPU ~500 Mo) + Whisper base (CPU) 148 Mo + Silero VAD 2 Mo + Android OS + app ~3 500 Mo + ────────────────────────────────────────────── + TOTAL estimé ~6 150 Mo + MARGE sur 16 Go ~9 850 Mo + +Largement confortable. Vous pourriez même monter le LLM à Qwen3-4B si la qualité empathique l'exige, au prix d'un export sur serveur cloud (130 Go RAM nécessaires). + +**Le pipeline complet actuel vs cible :** + + ACTUEL (fonctionnel) : + VAD (RMS) → Whisper CPU (1500ms) → LLM NPU (46 tok/s) → Android TTS (200ms) + Latence totale : 3-7s + + CIBLE (avec les pièces déjà prêtes) : + Silero VAD → Whisper NPU (~50ms) → LLM NPU (46 tok/s) → Qwen3-TTS NPU (~1.9s) + Latence totale estimée : ~2-4s avec clonage vocal + +**Mes recommandations pour la suite :** + +La priorité numéro un serait d'intégrer le **Whisper NPU** qui est déjà compilé. Passer de 1500ms à 50ms c'est 1.4 seconde gagnée sur chaque interaction — c'est le gain le plus facile à récupérer. + +La priorité numéro deux serait d'assembler le **pipeline TTS Qwen3-TTS complet** sur la tablette : talker NPU + code predictor CPU + speaker encoder CPU + speech decoder CPU. Le rapport montre que c'est ~1.9s pour une phrase standard. Si ça fonctionne, vous aurez le clonage vocal en temps réel sur tablette — un différenciateur majeur pour Kazeia. + +La priorité numéro trois serait de remplacer le **VAD basé sur l'énergie RMS** par **Silero VAD** pour une détection de parole plus robuste en milieu hospitalier (bruit ambiant, voix faibles). + +Ensuite viendrait l'intégration native en Kotlin au lieu du subprocess `su` pour ExecuTorch, le streaming LLM token par token vers l'UI, et le fine-tuning du Qwen3-1.7B sur des données thérapeutiques. + +Franchement, le travail accompli est impressionnant. Vous avez un pipeline conversationnel complet sur tablette Android avec NPU en quelques jours. Le cœur technique de Kazeia est validé. Tu veux qu'on se concentre sur une de ces priorités, ou qu'on fasse un document récapitulatif de toute l'architecture validée pour le mémoire ? + +--- + +You asked: +---------- + +### RAPPORT\_TTS.md + +254 lines + +md + +Donc si on se base sur le rapport\_TTS, nous sommes bloqué pour le TTS local, il faut qu'on refléchisse comment faire, l'ideal serait quand meme l'utilisation de qwen3-tts + +Mar 29 + +--- + +Claude Replied: +--------------- + +Analysé blocages techniques et envisagé solutions d'implémentation locale + +Analysé blocages techniques et envisagé solutions d'implémentation locale + +Tu as raison, c'est le vrai blocage. Le talker tourne à 90.7 tok/s sur le NPU — c'est résolu. Mais les trois autres composants (Speaker Encoder, Code Predictor, Speech Decoder) ne sont pas encore déployés sur la tablette. Regardons les options concrètement. + +Évalué obstacles déploiement TTS et envisagé solutions alternatives + +Évalué obstacles déploiement TTS et envisagé solutions alternatives + +Bon, récapitulons exactement où on en est et quelles sont les options réalistes. + +**L'état des lieux du rapport TTS :** + + Composant Params NPU (.pte) ONNX CPU PyTorch + ───────────────────────────────────────────────────────────────── + Talker LM 754M ✅ 90.7 tok/s ✗ ✅ (lent) + Code Predictor 141M ✗ ✗ ✅ + Speaker Encoder 8.9M non tenté non tenté ✅ + Speech Decoder 114M non tenté ✗ ✅ + +Le talker représente **87% du temps de calcul** et il est résolu. Les trois composants restants prennent ~1.5s sur CPU PC. Sur le CPU Oryon de la tablette (plus rapide que la plupart des CPU laptop sur du single-thread ARM), ça devrait donner **1-2s**. Le pipeline total serait donc ~0.3s (talker NPU) + ~1.5s (CPU) = **~1.8s** pour une phrase de 2-3 secondes d'audio. C'est du quasi temps réel. + +Le problème c'est **comment exécuter ces 3 composants sur la tablette**. Et c'est là que la trouvaille est intéressante : un port Rust complet de Qwen3-TTS existe, avec le `TalkerModel`, le `CodePredictor` (5 couches autorégressives, hidden\_size=1024, 15 codebooks), et le `SpeechDecoder` [Rust](https://docs.rs/speakers-qwen3-tts/latest/qwen3_tts/). Il tourne entièrement sur CPU sans dépendance CUDA. + +**Voici les quatre pistes concrètes, de la plus rapide à la plus propre :** + +**Piste 1 — Termux + PyTorch CPU sur la tablette (1-2 jours)** + +C'est le workaround le plus rapide. Tu installes Termux sur la tablette, tu y mets Python 3.10 + PyTorch CPU + les poids des 3 composants. Le talker tourne sur le NPU via le subprocess ExecuTorch, les tokens reviennent dans Python, le code predictor + speaker encoder + speech decoder tournent en PyTorch CPU natif. L'app Kotlin orchestre le tout en appelant un script Python via subprocess. + +C'est moche architecturalement — deux runtimes (ExecuTorch natif + Python/PyTorch), une communication inter-process, des latences de démarrage Python. Mais ça marche, et ça permet de **valider la qualité audio end-to-end** immédiatement. Si le résultat sonore est bon, ça justifie l'effort d'une intégration plus propre. + +**Piste 2 — Le crate Rust cross-compilé pour Android (1-2 semaines)** + +Le crate `qwen3_tts` en Rust implémente déjà tout le pipeline en pur CPU. Il expose le TalkerModel, le CodePredictor (5 couches autorégressives, 15 codebooks avec hidden\_size=1024), et le SpeechDecoder complet [Rust](https://docs.rs/speakers-qwen3-tts/latest/qwen3_tts/). Rust se cross-compile très bien vers Android ARM64 via le NDK. Tu obtiendrais une bibliothèque `.so` native que tu appelles via JNI depuis Kotlin. + +L'approche hybride serait d'utiliser le crate Rust uniquement pour le code predictor, le speaker encoder et le speech decoder (les composants CPU), tout en gardant le talker sur le NPU via ExecuTorch. Tu remplacerais le `TalkerModel` du crate Rust par un bridge qui appelle le `.pte` ExecuTorch. Concrètement, le flux serait : Kotlin appelle ExecuTorch JNI pour le talker → les speech tokens reviennent → Kotlin appelle la lib Rust JNI pour code predictor + decoder → audio WAV sort. + +L'avantage c'est que tout est natif, pas de Python, pas de Termux, performances optimales sur CPU ARM. L'inconvénient c'est le travail de bridge Rust ↔ ExecuTorch ↔ JNI. + +**Piste 3 — Réexporter les composants en ONNX avec des workarounds (1-2 semaines)** + +Le rapport dit que l'export ONNX du code predictor et du speech decoder a échoué. Mais les raisons d'échec sont spécifiques et potentiellement contournables. Le code predictor échoue à cause de sa structure "multi-tête" (15 codebooks en boucle autorégressif). La solution serait de dérouler la boucle sur les 15 codebooks et d'exporter chaque step comme un module ONNX séparé, ou de réécrire le forward pour être compatible avec `torch.onnx.export()` (shapes statiques, pas de boucles Python). Le speech decoder échoue à cause de convolutions dynamiques et de boucles. Un ConvNet causal avec des shapes fixes devrait être exportable en fixant les dimensions d'entrée. + +Si ça fonctionne, tu obtiens des fichiers `.onnx` que tu charges via ONNX Runtime Android — que tu as déjà dans tes dépendances pour Silero VAD. Un seul runtime ONNX pour le VAD et les composants TTS CPU. + +**Piste 4 — Tout exporter via ExecuTorch en CPU fallback (2-3 semaines)** + +ExecuTorch supporte un mode CPU (sans QNN delegate) où les opérations non-supportées par le NPU tombent automatiquement sur le CPU. Tu pourrais tenter `torch.export()` pour le code predictor et le speech decoder sans le QnnPartitioner — juste en mode XNNPACK (CPU optimisé ARM). Les opérations problématiques pour le NPU (boucles, shapes dynamiques) sont souvent supportées en CPU par ExecuTorch. + +L'avantage c'est un seul runtime ExecuTorch pour tout (talker NPU + reste CPU), un seul système de build, un seul JNI bridge. L'inconvénient c'est que `torch.export()` est strict sur les shapes dynamiques et les boucles Python — les mêmes limitations qui ont fait échouer l'export la première fois. + +**Ma recommandation :** + +Commence par la **Piste 1** (Termux + PyTorch) pour valider la qualité audio end-to-end en 1-2 jours. Si le résultat sonore est satisfaisant (clonage vocal naturel en français, pas d'artefacts), alors investis dans la **Piste 2** (crate Rust cross-compilé) pour une intégration production propre. Le crate Rust est le chemin le plus sûr parce que quelqu'un a déjà résolu tous les problèmes d'implémentation — tu n'as qu'à cross-compiler pour ARM64 et écrire le bridge JNI. + +La Piste 3 (ONNX) est un bon plan B si le Rust pose des problèmes de cross-compilation ou de taille de bibliothèque. Et la Piste 4 (ExecuTorch CPU) est la plus élégante mais la plus risquée. + +**Le plan d'action concret :** + + Semaine 1 — Validation qualité (Piste 1) + ├── Installer Termux + Python 3.10 + PyTorch CPU sur tablette + ├── Pousser les poids du code predictor + speaker encoder + decoder + ├── Script d'orchestration : talker NPU → tokens → PyTorch CPU → WAV + ├── Tester avec les voix françaises (Damien, Élodie, etc.) + └── Valider : qualité audio OK ? Latence < 3s ? Pas d'artefacts ? + + Semaine 2-3 — Intégration native (Piste 2) + ├── Cross-compiler le crate qwen3_tts Rust pour Android ARM64 + ├── Extraire uniquement code_predictor + speaker_encoder + speech_decoder + ├── Écrire le bridge JNI (Kotlin → Rust .so) + ├── Connecter au talker ExecuTorch NPU existant + ├── Intégrer dans le Foreground Service Kazeia + └── Benchmarker la latence totale end-to-end sur tablette + + Semaine 3-4 — Optimisation + ├── Pré-calculer le x-vector des voix de référence (offline) + ├── Streaming : décoder l'audio par paquets de 4 tokens + │ (pendant que le talker continue de générer) + ├── Cache le speaker encoder (un seul appel par session) + └── Mesurer la consommation batterie du pipeline complet + +Un dernier point important : le speaker encoder (8.9M paramètres) n'a besoin de tourner qu'**une seule fois par voix**. Tu peux pré-calculer le x-vector de chaque voix de référence (Damien, Élodie, etc.) sur PC et embarquer directement les vecteurs sur la tablette. Ça élimine complètement ce composant du runtime. + +Tu veux qu'on détaille la Piste 1 pour que ta session Claude Code puisse démarrer la validation, ou qu'on explore le crate Rust en détail ? + +--- + +You asked: +---------- + +Je vais faire une parenthèse, il semble que google ait devoillé TurboQuant qui permettrait de réduire l'empreinte mémoire des LLM, je souhaiterai que tu récupères tous les articles sur le sujet et que tu étudies l'algorythme pour voir si cela nous permettrait d'optimiser notre code sur tablette + +Mar 31 + +--- + +Claude Replied: +--------------- + +Excellente veille technologique. Si Google a sorti une nouvelle technique de quantification qui réduit l'empreinte mémoire des LLM, ça pourrait être un game changer pour Kazeia — notamment pour faire tourner le Qwen3-4B sur le NPU avec moins de RAM, ou même rendre le 1.7B encore plus léger. Je lance une recherche approfondie tout de suite. + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +TurboQuant algorithm research + +![](https://www.google.com/s2/favicons?domain=research.google&sz=32) + +![](https://www.google.com/s2/favicons?domain=turboquant.net&sz=32) + +![](https://www.google.com/s2/favicons?domain=cybernews.com&sz=32) + +Research complete• + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +265 sources + +• + +4m 47s + +Your TurboQuant research report is ready. It clarifies that TurboQuant is a KV cache compression method—not a weight quantization technique—and explains why it's largely irrelevant to Kazeia's on-device deployment challenges with Qwen3 on ExecuTorch + QNN. The report covers what TurboQuant actually does, a critical community-discovered flaw in its QJL stage, why it doesn't address your export RAM or NPU inference bottlenecks, and recommends actionable alternatives including QAT-trained INT4 checkpoints, Google's LiteRT + QNN Accelerator stack, and hardware-aware tile quantization for Qualcomm Hexagon NPUs. + +TurboQuant KV Cache Compression: Why It Won't Solve Kazeia's On-Device Deployment Challenges + +Document  + +\# TurboQuant is real — but won't solve your problem \*\*TurboQuant is a KV cache compression algorithm, not a weight quantization method, making it largely irrelevant to the Kazeia project's on-device deployment challenges.\*\* Google Research announced + +--- + +You asked: +---------- + +Donc pour toi est ce que nous pourrions tirer profit de cette idée pour le NPU et la tablette ? + +Mar 31 + +--- + +Claude Replied: +--------------- + +Évalué la pertinence de TurboQuant pour configuration NPU tablette + +Évalué la pertinence de TurboQuant pour configuration NPU tablette + +Non, pas dans votre cas. Et voici pourquoi en une phrase : **TurboQuant compresse le KV cache, pas les poids du modèle** — or vos problèmes sont tous liés aux poids. + +Regardons vos trois goulots d'étranglement concrets et ce que TurboQuant changerait pour chacun : + +**L'export RAM (17 Go pour 0.6B, 48-64 Go pour 1.7B)** — c'est le pipeline ExecuTorch qui matérialise le graphe complet des poids en mémoire pour la compilation QNN. TurboQuant ne touche pas aux poids, donc zéro impact ici. L'export consommera exactement la même RAM avec ou sans TurboQuant. + +**La taille des modèles sur la tablette (660 Mo pour 0.6B, 1.7 Go pour 1.7B)** — c'est le volume des poids quantifiés. TurboQuant ne les compresse pas. Un Qwen3-1.7B en INT4 fera toujours ~850 Mo de poids, que le KV cache soit compressé ou non. + +**La vitesse d'inférence (93 tok/s pour 0.6B, 46 tok/s pour 1.7B)** — sur le Snapdragon 8 Elite, le bottleneck c'est la bande passante mémoire pour charger les poids à chaque token (77 Go/s LPDDR5X). Le KV cache est minuscule à côté. Pour une conversation thérapeutique de 4-5 tours (disons 500 tokens de contexte), le KV cache en FP16 fait environ 500 × 2 × hidden\_size × num\_layers × 2 bytes. Pour Qwen3-1.7B (hidden\_size=2048, 24 layers), ça donne ~94 Mo. Compresser ça de 6× avec TurboQuant gagnerait ~78 Mo de RAM. C'est négligeable sur 16 Go. + +TurboQuant serait utile si vous aviez un modèle serveur avec des contextes de 32K-128K tokens et des dizaines d'utilisateurs simultanés. Pour un chatbot mono-utilisateur sur tablette avec des contextes courts, le KV cache n'est tout simplement pas le problème. + +**Ce qui vous aiderait réellement, c'est le QAT (Quantization-Aware Training).** La recherche montre que les modèles entraînés avec QAT perdent 54% moins de qualité en INT4 que ceux quantifiés après coup. Concrètement, si vous fine-tunez votre Qwen3-1.7B thérapeutique avec `qat_scheme = "int8-int4"` (comme le propose Unsloth pour l'export ExecuTorch), le modèle apprend pendant l'entraînement à compenser les erreurs de quantification. Le résultat est un modèle INT4 qui se comporte presque comme un FP16, au lieu du compromis actuel où la quantification post-training dégrade la qualité empathique. + +L'autre piste intéressante mentionnée dans la recherche c'est **LiteRT + QNN Accelerator** de Google, co-développé avec Qualcomm spécifiquement pour le Snapdragon 8 Elite. Il annonce des performances supérieures à ExecuTorch sur certains benchmarks. C'est à évaluer, mais c'est un changement de runtime complet — pas un quick win. + +En résumé : TurboQuant est une vraie avancée scientifique, mais pour un cas d'usage server-side avec long contexte. Pour Kazeia sur tablette, c'est hors cible. Concentrez vos efforts sur le QAT pour le fine-tuning et sur le déploiement du pipeline TTS Qwen3 — c'est là que se trouvent vos vrais gains. + +--- + +You asked: +---------- + +Alors est ce que l'utilisation de LiteRT + QNN Accelerator en lieu et place de executorch augmenterai les performances ? + +Mar 31 + +--- + +Claude Replied: +--------------- + +Réfléchissant aux gains de performance en remplaçant ExecuTorch par LiteRT et QNN Accelerator + +Réfléchissant aux gains de performance en remplaçant ExecuTorch par LiteRT et QNN Accelerator + +Très bonne question — c'est potentiellement un changement de runtime important. Laisse-moi chercher les benchmarks concrets avant de répondre. + +Analysé comparaison LiteRT et ExecuTorch pour optimisation + +Analysé comparaison LiteRT et ExecuTorch pour optimisation + +La réponse courte : **les chiffres LiteRT sont impressionnants mais ne s'appliquent pas directement à votre cas**. Décortiquons pourquoi. + +**Ce que LiteRT + QNN Accelerator annonce :** + +Google a benchmarké le LiteRT QNN Accelerator sur 72 modèles ML et obtient jusqu'à 100× plus rapide que le CPU et 10× plus rapide que le GPU sur le Snapdragon 8 Elite, avec 64 des 72 modèles entièrement délégués au NPU [Google Developers](https://developers.googleblog.com/unlocking-peak-performance-on-qualcomm-npu-with-litert/). Pour les LLM spécifiquement, le FastVLM-0.5B atteint plus de 11 000 tokens/s en prefill et plus de 100 tokens/s en decode sur le NPU du Snapdragon 8 Elite, avec un TTFT de seulement 0.12 seconde [Google Developers](https://developers.googleblog.com/unlocking-peak-performance-on-qualcomm-npu-with-litert/). + +100+ tok/s en decode pour un modèle 0.5B, c'est supérieur à vos 93 tok/s avec ExecuTorch sur le Qwen3-0.6B. C'est tentant. + +**Mais il y a des nuances critiques :** + +Premièrement, le benchmark FastVLM utilise une quantification **INT8 poids + INT16 activations**. C'est la clé pour débloquer les kernels INT16 haute vitesse du NPU. Google a également ajouté des kernels NPU spéciaux pour les couches d'attention des transformers [Google Developers](https://developers.googleblog.com/unlocking-peak-performance-on-qualcomm-npu-with-litert/). Votre pipeline ExecuTorch utilise INT4 poids (w4a16), qui est un profil de quantification différent. La comparaison n'est donc pas à armes égales — INT8 consomme plus de mémoire mais le NPU a des kernels optimisés pour ce format. Il faudrait retester avec le même schéma de quantification pour comparer. + +Deuxièmement, LiteRT part d'un format TFLite (.tflite), pas PyTorch. Le LiteRT QNN Accelerator remplace l'ancien delegate TFLite QNN et fournit une API unifiée qui abstrait la fragmentation entre les SoC [Edge AI and Vision Alliance](https://www.edge-ai-vision.com/2025/11/google-announces-litert-qualcomm-ai-engine-direct-accelerator/). Ça signifie que pour utiliser LiteRT, vous devriez convertir vos modèles Qwen3 de PyTorch vers TFLite d'abord (via ONNX → TFLite ou via AI Edge Torch), puis laisser LiteRT compiler pour le NPU. C'est un pipeline de conversion complètement différent de votre chaîne actuelle `torch.export() → QnnPartitioner → .pte`. Tout votre travail d'export ExecuTorch (les patches GCC 15, les scripts de conversion, les tokenizers) serait à refaire. + +Troisièmement, LiteRT QNN ne supporte que les appareils avec Snapdragon 8 et Snapdragon 8+ SoC [InfoQ](https://www.infoq.com/news/2025/11/litert-snapdragon-accelerator/). C'est votre cas (Snapdragon 8 Elite), mais les modèles LLM supportés sont principalement Gemma et FastVLM — des modèles Google. Qwen3 n'est pas mentionné dans les benchmarks LiteRT. Il faudrait vérifier que l'architecture Qwen3 (avec ses spécificités comme le QK-norm, le GQA, le RoPE theta 1M) est entièrement supportée par les 90 ops LiteRT. + +**Le vrai problème de fond :** + +La contrainte profonde sur mobile c'est la bande passante mémoire, pas la puissance de calcul. Les appareils mobiles ont 50-90 Go/s, les GPU datacenter ont 2-3 To/s — c'est un écart de 30-50×. Pour l'inférence LLM, cet écart est décisif car le decode est memory-bound : on charge tous les poids du modèle pour chaque token généré [V-chandra](https://v-chandra.github.io/on-device-llms/). + +Ça signifie que ni ExecuTorch ni LiteRT ne peuvent dépasser la limite physique de la bande passante LPDDR5X de votre tablette (~77 Go/s). Pour un modèle de 1.7B en INT4 (~850 Mo de poids), le débit théorique maximum est 77 Go/s ÷ 0.85 Go = ~90 tokens/s. Votre ExecuTorch mesure 46 tok/s, soit environ 50% de l'efficacité théorique. LiteRT pourrait gagner quelques pourcents grâce à ses kernels d'attention optimisés, mais **pas doubler la performance** — le plafond physique est le même. + +Pour le Qwen3-0.6B (660 Mo), votre ExecuTorch mesure 93 tok/s. Le maximum théorique est ~115 tok/s. Vous êtes déjà à 80% d'efficacité. LiteRT pourrait grappiller les 20% restants, peut-être atteindre 100-110 tok/s, mais pas 200 tok/s. + +**Il y a cependant une piste LiteRT réellement intéressante :** + +Des chercheurs de l'EuroSys 2026 ont développé un système d'inférence LLM sur NPU Qualcomm Hexagon qui atteint un speedup de 19× pour les GEMM en précision mixte en alignant les groupes de quantification avec les patterns d'accès mémoire du NPU [arXiv](https://arxiv.org/html/2509.23324v1). C'est une optimisation bas niveau qui exploite la micro-architecture HMX (Hexagon Matrix eXtension) — et c'est exactement ce type d'optimisation que LiteRT intègre avec ses kernels spéciaux. + +Et il y a aussi **mllm-NPU** qui est intéressant : le framework MLLM supporte depuis février 2026 l'exécution complète de graphes sur NPU via QNN AOT, avec Qwen3 et DeepSeek supportés en streaming sur Android [GitHub](https://github.com/UbiquitousLearning/mllm). C'est une alternative à ExecuTorch qui mérite d'être surveillée. + +**Ma recommandation pour Kazeia :** + +Ne changez pas de runtime maintenant. ExecuTorch + QNN fonctionne, il est validé sur votre tablette, vous avez investi du temps dans le pipeline de conversion et résolu les problèmes de toolchain. Le gain potentiel de LiteRT est de l'ordre de 10-20% sur le decode — pas un game changer. + +Le vrai levier pour améliorer les performances serait de passer le Qwen3-1.7B de w4a16 à **w8a16** avec les kernels INT16 optimisés du NPU (comme le fait LiteRT), mais dans ExecuTorch. Le QNN delegate d'ExecuTorch supporte aussi les kernels INT8/INT16 du Hexagon — il faudrait juste changer le profil de quantification à l'export. + +En revanche, si un jour vous voulez déployer Kazeia sur des tablettes de marques différentes (Samsung avec MediaTek, par exemple), LiteRT deviendrait pertinent grâce à sa portabilité multi-SoC. Mais pour le moment, avec une flotte de tablettes OnePlus Pad 3 identiques, ExecuTorch + QNN est le bon choix — et concentrez vos efforts sur le pipeline TTS Qwen3 et le fine-tuning du modèle thérapeutique. + +--- + +You asked: +---------- + +Mais je ne comprends pas, de ce que je lis, même si on plafonnerait à cause de la vitesse de la RAM, on pourrait augmenter le debut avec du INT8 ou INT16 comparé FP16 ou FP32 ? + +Mar 31 + +--- + +Claude Replied: +--------------- + +Reconciliait quantization theory avec observations empiriques du pipeline + +Reconciliait quantization theory avec observations empiriques du pipeline + +Tu as tout à fait raison, et c'est moi qui n'ai pas été assez clair. Le raisonnement est simple et correct : + +**La bande passante est fixe à ~77 Go/s. Plus les poids sont petits, plus tu en charges par seconde, plus tu génères de tokens.** + +Concrètement, pour un modèle de 1.7B paramètres : + + Format des poids Taille modèle Débit théorique max + ───────────────────────────────────────────────────────── + FP32 (4 octets) 6.8 Go 77 ÷ 6.8 = ~11 tok/s + FP16 (2 octets) 3.4 Go 77 ÷ 3.4 = ~22 tok/s + INT8 (1 octet) 1.7 Go 77 ÷ 1.7 = ~45 tok/s + INT4 (0.5 octet) 0.85 Go 77 ÷ 0.85 = ~90 tok/s + +Donc oui, passer de FP16 à INT8 **doublerait** le débit théorique, et passer à INT4 le **quadruplerait** par rapport à FP16. Ton intuition est correcte. + +**La bonne nouvelle c'est que votre pipeline ExecuTorch utilise déjà INT4 (w4a16).** Vos Qwen3 sont exportés en poids INT4 avec activations FP16. C'est le format le plus compressé — vous êtes déjà au maximum de ce que la bande passante permet en théorie. Vos 46 tok/s mesurés pour le Qwen3-1.7B représentent environ 50% du maximum théorique de 90 tok/s. Les 50% manquants sont perdus dans les overheads : la déquantification INT4 → FP16 avant le calcul matriciel, la latence de scheduling CPU ↔ NPU, les ops qui ne tournent pas sur le NPU (softmax, layer norm, RoPE), et le KV cache en FP16. + +**Là où ta question devient vraiment pertinente, c'est sur le format de calcul du NPU.** + +Le Hexagon HTP v79 a une unité matricielle HMX qui a des modes de calcul natifs. Elle peut faire du INT8 × INT8, du INT8 × INT16, et du FP16 × FP16 à pleine vitesse. Mais pour le INT4, le NPU doit **décompresser** les poids INT4 en INT8 ou INT16 avant de les multiplier — il n'y a pas de matmul INT4 natif sur le Hexagon. Cette décompression coûte des cycles. + +C'est exactement ce que LiteRT exploite. Leur modèle FastVLM utilise des poids INT8 avec des activations INT16, ce qui débloque les kernels INT16 haute vitesse natifs du NPU [Google Developers](https://developers.googleblog.com/unlocking-peak-performance-on-qualcomm-npu-with-litert/). Le NPU fait un matmul INT8 × INT16 directement, sans décompression — c'est une opération native du HMX à pleine puissance. + +**Le compromis est le suivant :** + + Bande passante Compute NPU Résultat + (moins = mieux) (natif = mieux) + ──────────────────────────────────────────────────────────────────── + INT4 poids (w4a16) ✅ Optimal ❌ Décompression Bon mais overhead + INT8 poids (w8a16) ⚠️ 2× plus gros ✅ Natif HMX Potentiellement + rapide + FP16 poids ❌ 4× plus gros ✅ Natif HMX Trop gros en mémoire + +Pour les petits modèles (0.6B) qui tiennent largement en RAM, passer en **INT8 (w8a16)** pourrait être plus rapide que INT4 parce que le NPU calcule en natif sans overhead de décompression, même si on charge 2× plus de données. Le modèle passerait de 660 Mo à ~1.3 Go — ça tient toujours confortablement en mémoire. + +Pour le Qwen3-1.7B, c'est plus serré. En INT8 il ferait ~1.7 Go au lieu de 850 Mo. Ça tient en RAM, mais le débit théorique tombe à ~45 tok/s (limité par la bande passante), donc proche de vos 46 tok/s actuels en INT4. Le gain du compute natif serait compensé par la perte en bande passante. C'est à peu près neutre. + +Pour le Qwen3-4B, passer en INT8 signifierait ~4 Go de poids — ça tient mais ça devient tendu avec tous les autres composants. Et le débit théorique tomberait à ~19 tok/s, ce qui est inférieur à vos 20 tok/s actuels via Genie en INT4. + +**Concrètement, ce qu'on pourrait tester :** + +Le gain le plus probable serait sur le **Qwen3-0.6B en INT8** : le modèle est petit (1.3 Go en INT8), la bande passante n'est pas le bottleneck, et le NPU calculerait en mode natif INT8 × INT16. On pourrait passer de 93 tok/s à potentiellement **110-120 tok/s**. C'est le même principe qui permet à LiteRT d'atteindre 100+ tok/s sur FastVLM-0.5B. + +Pour tester ça avec ExecuTorch, il suffit de changer le profil de quantification à l'export : + +python + + # Actuellement (INT4 poids) + backend_options = generate_htp_compiler_spec(use_fp16=False) # quantized INT4 + + # À tester (INT8 poids, INT16 activations) + backend_options = generate_htp_compiler_spec(use_fp16=False) + # + changer le quantizer pour INT8 au lieu de INT4 dans le script d'export + +C'est une expérience rapide que ta session Claude Code pourrait faire — re-exporter le Qwen3-0.6B en INT8 avec le même pipeline ExecuTorch, pousser le nouveau `.pte` sur la tablette, et comparer les tok/s. Si le gain est significatif, ça valide l'hypothèse et on peut décider si le tradeoff taille/vitesse est acceptable pour chaque modèle. + +Tu veux qu'on prépare les paramètres exacts pour que ta session Claude Code fasse ce test ? + +--- + +You asked: +---------- + +Donc techniquement sur la tablette que nous avons quelle serait la combinaison quantization que nous devrions utiliser pour exploiter au mieux ces ressources + +Mar 31 + +--- + +Claude Replied: +--------------- + +Analyzed hardware capabilities pour optimiser quantization multi-modèles + +Analyzed hardware capabilities pour optimiser quantization multi-modèles + +Excellente question — c'est le genre d'optimisation qui peut faire une vraie différence. Laisse-moi d'abord vérifier les specs précises du HMX du Hexagon HTP v79 pour ne pas spéculer. + +Synthesized optimal quantization strategy balancing hardware capabilities with software constraints + +Synthesized optimal quantization strategy balancing hardware capabilities with software constraints + +Les specs hardware sont maintenant claires. Voici exactement ce que le HMX de votre Snapdragon 8 Elite supporte et la stratégie optimale pour chaque composant. + +**Ce que le HMX sait faire nativement :** + +L'unité HMX supporte les précisions INT4, INT8, INT16 et FP16. L'unité de base est une "tile" — pour le FP16, chaque tile est une matrice 32×32 occupant 2 Ko [arXiv](https://arxiv.org/html/2509.23324v1). Le coprocesseur HMX supporte spécifiquement les modes INT8, A16int8 (activations 16 bits avec poids INT8), A8int4 (activations 8 bits avec poids INT4), et FP16 [TechWiser](https://techwiser.com/snapdragon-8-elite-specs-features-ahead-of-launch/). + +C'est la clé. Le HMX a des **modes de calcul matriciel natifs** — il ne fait pas que du stockage en INT4, il sait aussi calculer avec. Mais chaque mode a un profil de performance différent. + +**Le vrai tableau des TOPS par précision :** + + Mode HMX Compute relatif Bande passante poids + ────────────────────────────────────────────────────────────── + FP16 × FP16 1× (baseline) 2 octets/param + INT8 × INT16 (w8a16) ~2× (tiles 2× +) 1 octet/param + INT4 × INT8 (w4a8) ~4× (tiles 4× +) 0.5 octet/param + INT4 via LUT → FP16 ~1.5-2× 0.5 octet/param + overhead LUT + +Le Snapdragon 8 Elite a un support hardware pour les calculs INT4, mais le support logiciel est encore en retard — ça change rapidement [Embedl](https://www.hub.embedl.com/blog/qualcomms-latest-flagship). Concrètement, le QNN SDK propose deux chemins pour les poids INT4 : soit une décompression LUT vers FP16 puis calcul FP16 (c'est ce que fait ExecuTorch actuellement avec w4a16), soit un calcul natif A8int4 — mais ce dernier nécessite aussi des activations INT8, ce qui demande une quantification des activations. + +**Là où ça devient intéressant pour Kazeia :** + +Votre pipeline actuel est **w4a16** : poids INT4, activations FP16. Le HMX décompresse les INT4 en FP16 via LUT puis fait le matmul en FP16. C'est bien, mais vous n'exploitez pas le mode natif le plus puissant du NPU. + +Le mode optimal serait **w4a8** ou **w8a8** : poids INT4 ou INT8, activations INT8. Dans ce mode, l'architecture est précisément optimisée pour l'inférence avec des modèles quantifiés, favorisant le format INT8 dans lequel elle a toujours excellé [Emergent Mind](https://www.emergentmind.com/topics/qualcomm-sm8750-ab-snapdragon-8-elite-hexagon-tensor-processor-htp). Le HMX fait un matmul INT × INT natif à pleine puissance, sans décompression, sans conversion FP16. + +**La stratégie optimale par composant de Kazeia :** + + Composant Poids Activations KV Cache Pourquoi + ───────────────────────────────────────────────────────────────────── + LLM Qwen3-1.7B INT4 INT8 (w4a8) INT8 Meilleur débit decode + INT4→moitié bande passante + INT8 activations→compute natif + + LLM Qwen3-0.6B INT8 INT8 (w8a8) INT8 Assez petit pour INT8 + Tient en ~660 Mo + Full compute natif HMX + Meilleure qualité que INT4 + + TTS Talker (754M) INT8 INT8 (w8a8) INT8 Qualité audio critique + INT4 risque artefacts sonores + ~750 Mo tient en RAM + + Whisper Encoder INT8 INT8 (w8a8) N/A Prefill-only (pas autorégressif) + Compute-bound, pas bandwidth-bound + INT8 natif = max TOPS + + Silero VAD FP32 FP32 N/A Minuscule (1.8 Mo) + Tourne sur CPU, pas NPU + Pas besoin d'optimiser + +**Pourquoi w4a8 serait le sweet spot pour le LLM 1.7B :** + +Le decode LLM est un GEMV (matrix-vector multiply) — tu charges tous les poids pour multiplier par un seul vecteur d'activation. C'est bandwidth-bound. Les poids en INT4 minimisent la bande passante (0.5 octet par paramètre). Mais les activations n'ont pas besoin d'être en FP16 — elles peuvent être en INT8 sans perte significative de qualité. Et avec des activations INT8, le HMX utilise son mode **A8int4 natif** au lieu de devoir décompresser les INT4 en FP16. + +Le gain estimé : + + Actuel (w4a16) : + Poids: 0.5 octet/param → 850 Mo → 77/0.85 = 90 tok/s théorique + Mais: décompression INT4→FP16 + matmul FP16 = ~50% efficacité + Résultat mesuré: 46 tok/s + + Optimal (w4a8) : + Poids: 0.5 octet/param → 850 Mo → même bande passante + Mais: matmul A8int4 natif HMX = ~70-80% efficacité + Résultat estimé: 63-72 tok/s (+40-55%) + +**Mais il y a un obstacle logiciel important.** + +QNN ne supporte que la quantification per-tensor ou per-channel pour les poids. Les méthodes de quantification fine-grained par groupe, essentielles pour les LLM modernes, ne sont pas supportées nativement [arXiv](https://arxiv.org/html/2509.23324v1). Or les LLM comme Qwen3 ont besoin de quantification par groupe (typiquement groupe de 32 ou 128) pour maintenir la qualité en INT4. C'est exactement pour ça que le paper d'EuroSys propose la **tile quantization** : ils alignent les groupes de quantification avec les patterns d'accès mémoire du HMX, obtenant un speedup de 19× pour les GEMM en précision mixte [arXiv](https://arxiv.org/html/2509.23324v1). + +ExecuTorch contourne ce problème en utilisant la quantification per-channel pour les poids et en laissant les activations en FP16 (w4a16). C'est pourquoi vos performances actuelles sont à ~50% du théorique — le pipeline n'exploite pas le mode INT natif du HMX. + +**Ce qu'il faudrait faire concrètement :** + +La solution idéale serait de re-exporter vos modèles avec un profil de quantification **INT8 activations** au lieu de FP16. Dans ExecuTorch, ça se configure au niveau du quantizer : + +python + + # Actuel : w4a16 (poids INT4, activations FP16) + from executorch.backends.qualcomm.quantizer import QnnQuantizer + quantizer = QnnQuantizer() + quantizer.set_bit16_op_quant_config(...) # activations en FP16 + + # Cible : w4a8 ou w8a8 (poids INT4/8, activations INT8) + quantizer.set_per_channel_weight_dtype(weight_dtype_wt=torch.int8) + quantizer.set_per_tensor_act_dtype(act_dtype=torch.int8) + +Les tests à faire par la session Claude Code seraient, dans l'ordre : + +Premièrement, Qwen3-0.6B en **w8a8** (poids INT8, activations INT8) — c'est le plus simple car tout est INT8 natif. Comparer avec vos 93 tok/s actuels en w4a16. Si ça monte à 100-120 tok/s, le gain du compute natif compense la bande passante doublée. + +Deuxièmement, Qwen3-0.6B en **w4a8** (poids INT4, activations INT8) — c'est le meilleur des deux mondes. Même bande passante poids qu'actuellement mais compute natif HMX. C'est celui qui devrait donner le plus gros gain. + +Troisièmement, si w4a8 fonctionne, appliquer le même profil au **Qwen3-1.7B** et au **talker TTS**. + +L'enjeu c'est que le w4a8 n'est peut-être pas directement supporté par le pipeline d'export ExecuTorch + QNN actuel — il faudra peut-être patcher le quantizer ou utiliser une calibration spécifique. Mais c'est exactement le type d'optimisation qui pourrait transformer vos 46 tok/s en 65-70 tok/s sur le 1.7B, sans changer de modèle ni de hardware. + +Tu veux qu'on prépare les instructions détaillées pour que ta session Claude Code teste ces profils de quantification ? + +--- + +You asked: +---------- + +Et via des ré-implémentation via LireRT ou mllm-NPU, crois tu qu'on pourrait gagner en performance ? + +Mar 31 + +--- + +Claude Replied: +--------------- + +Investigated performance gains between framework implementations + +Investigated performance gains between framework implementations + +Bonne question — maintenant qu'on comprend que le bottleneck est l'efficacité d'utilisation du HMX, regardons si d'autres runtimes l'exploitent mieux. + +Crystallized analysis of three runtime performance options + +Crystallized analysis of three runtime performance options + +Les résultats de la recherche sont très éclairants. La réponse est **oui, significativement** — mais pas avec LiteRT. Voici pourquoi. + +**Le problème fondamental de tous les runtimes actuels (ExecuTorch, LiteRT, QNN) :** + +Les systèmes NPU existants ont tous des limitations : mllm-NPU n'utilise pas le NPU pendant la phase de decode, les systèmes basés sur QNN ont une faible précision (comme PowerServe), et d'autres comme Powerinfer-2 et HeteroLLM ne sont pas open-source [arXiv](https://arxiv.org/html/2509.23324v1). + +Dit autrement : aucun des frameworks grand public n'exploite correctement le NPU Hexagon pour l'inférence LLM. Ils utilisent tous QNN comme une boîte noire — et QNN n'est pas optimisé pour le pattern spécifique du LLM decode (GEMV, poids quantifiés par groupe, KV cache dynamique). + +**C'est exactement ce que le paper EuroSys 2026 a démontré.** Ces chercheurs ont **reverse-engineeré les instructions HMX non documentées** pour programmer le NPU directement : + +Les chercheurs ont pu utiliser l'unité matricielle FP16 en rétro-concevant les instructions non documentées dans les bibliothèques binaires. L'unité de base du HMX pour les opérations FP16 est une tile de 32×32 occupant 2 Ko [arXiv](https://arxiv.org/html/2509.23324v1). + +Et le résultat est spectaculaire. Leur système atteint un speedup de 19× pour les GEMM en précision mixte et 2.2× pour le softmax par rapport aux implémentations précédentes [arXiv](https://arxiv.org/html/2509.23324v1). Pour la première fois, mllm-NPU atteint plus de 1000 tokens/seconde en prefill pour un modèle de classe milliard (Qwen1.5-1.8B), et il est 7.3×-18.4× plus rapide que les baselines sur CPU [arXiv](https://arxiv.org/html/2407.05858v1/). + +**Maintenant, la question c'est quel runtime exploite le mieux le HMX pour le DECODE (pas le prefill) :** + +Le prefill (traiter le prompt) est compute-bound et bénéficie massivement du NPU. Mais le decode (générer token par token) est bandwidth-bound, et c'est là que les optimisations bas-niveau font la différence. Voici ce que chaque runtime offre : + +**ExecuTorch + QNN (votre runtime actuel)** utilise QNN comme backend opaque. QNN compile le graphe du modèle en context binaries optimisés pour le HTP, mais il utilise une quantification per-channel ou per-tensor limitée. L'architecture continue de favoriser les modèles en format INT8 quantifié, une modalité dans laquelle elle a toujours excellé [Emergent Mind](https://www.emergentmind.com/topics/qualcomm-sm8750-ab-snapdragon-8-elite-hexagon-tensor-processor-htp). Vos 46 tok/s sur Qwen3-1.7B en w4a16 représentent ~50% d'efficacité théorique — QNN fait un bon travail mais pas optimal. + +**LiteRT + QNN Accelerator** utilise le même QNN en dessous mais avec des kernels d'attention spécialisés ajoutés par Google. Google a ajouté des kernels NPU spéciaux pour les couches d'attention des transformers, en utilisant une quantification INT8 poids + INT16 activations pour débloquer les kernels haute vitesse du NPU [Google Developers](https://developers.googleblog.com/unlocking-peak-performance-on-qualcomm-npu-with-litert/). Le gain serait de l'ordre de **10-30%** sur le decode par rapport à ExecuTorch, principalement grâce à ces kernels d'attention optimisés. Mais ça reste au-dessus de QNN — pas d'accès direct au HMX. + +**Le système EuroSys 2026 (basé sur mllm-NPU)** — c'est le vrai game changer potentiel. Ces chercheurs ne passent pas par QNN pour le compute critique. Ils utilisent des instructions vlut16 pour convertir directement les valeurs INT4 quantifiées en FP16 via lookup table, éliminant la séquence conventionnelle mask-unpack-convert [arXiv](https://arxiv.org/html/2509.23324v1). Et surtout, leur **tile quantization** aligne les groupes de quantification avec le layout mémoire natif du HMX : + +Ils arrangent les poids avant la quantification selon un layout hiérarchiquement structuré en deux niveaux : un ordonnancement en colonne des tiles à l'extérieur, correspondant à l'opération de produit interne au niveau tile de l'unité matricielle, et un réarrangement interne [arXiv](https://arxiv.org/pdf/2509.23324). + +Le résultat sur la qualité est remarquable : le modèle utilisant leur layout de quantification par tiles a une précision légèrement supérieure sur MMLU comparé au layout conventionnel, avec seulement une légère baisse sur Winogrande et Wikitext [arXiv](https://arxiv.org/pdf/2509.23324). + +**Mon évaluation réaliste des gains possibles :** + + Runtime Qwen3-1.7B decode Gain vs actuel Effort + ──────────────────────────────────────────────────────────────────────── + ExecuTorch + QNN (actuel) 46 tok/s baseline 0 + ExecuTorch w4a8 (reconfig) ~55-60 tok/s +20-30% 1 semaine + LiteRT + QNN Accelerator ~55-65 tok/s +20-40% 3-4 semaines + (refonte pipeline) + mllm-NPU / EuroSys style ~70-85 tok/s +50-85% 2-3 mois + (reverse engineering HMX) + Théorique max (77 Go/s) 90 tok/s +95% impossible à atteindre + +**Ma recommandation pour Kazeia :** + +À court terme, le meilleur rapport effort/gain c'est de **reconfigurer ExecuTorch avec w4a8 ou w8a8**. Vous restez dans votre pipeline existant, vous changez juste le profil de quantification. C'est 1 semaine de travail pour un gain potentiel de 20-30%. + +À moyen terme, surveille de très près le **framework MLLM**. MLLM supporte depuis février 2026 l'exécution complète de graphes sur NPU via QNN AOT, avec Qwen3 supporté en streaming sur Android [GitHub](https://github.com/UbiquitousLearning/mllm). C'est open source, activement développé, et il intègre déjà les optimisations du paper mllm-NPU (chunked prefill, outlier extraction, shape profiling). Si l'équipe MLLM intègre les optimisations tile quantization du paper EuroSys, ça pourrait devenir le meilleur runtime pour LLM sur Hexagon — avec des gains de 50%+ par rapport à ExecuTorch/QNN. + +LiteRT ne vaut pas le coût de migration. Le gain de 20-40% ne justifie pas de refaire tout le pipeline de conversion (PyTorch → TFLite → LiteRT) et de perdre la compatibilité avec vos modèles custom Qwen3-TTS et vos scripts d'export ExecuTorch. + +Le vrai levier à long terme pour Kazeia serait de contribuer aux optimisations MLLM pour le Snapdragon 8 Elite — c'est un travail de recherche qui pourrait aussi alimenter votre mémoire UTBM. + +Tu veux qu'on creuse le framework MLLM pour évaluer une migration, ou qu'on reste sur l'optimisation du profil de quantification ExecuTorch pour le court terme ? + +--- + +You asked: +---------- + +Je veux bien que tu creuses le framework MLLM et ces possibilités sur le snapdragon 8 elite + +Mar 31 + +--- + +Claude Replied: +--------------- + +Démêlé les capacités et limitations du framework MLLM + +Démêlé les capacités et limitations du framework MLLM + +L'analyse est maintenant complète, et le tableau est plus nuancé que prévu. MLLM a des forces réelles mais aussi un défaut critique pour Kazeia. + +**Ce qu'est MLLM :** + +MLLM est le hub central de la stack d'inférence IA, connectant les algorithmes d'optimisation (décodage spéculatif, pruning, quantification) avec les couches de compilation/runtime (CANN, CUDA, MLIR) pour l'exécution hardware. Il supporte de manière unifiée le CPU ARM, le GPU OpenCL, et le NPU QNN [GitHub](https://github.com/UbiquitousLearning/mllm). C'est un projet académique de Peking University et Beijing University of Posts and Telecommunications, avec une communauté active. + +Depuis février 2026, MLLM supporte l'exécution AOT complète de graphes sur NPU via QNN. Il supporte aussi le streaming stable de Qwen3 et DeepSeek sur Android via une architecture client-serveur embarquée utilisant un serveur Go in-app [GitHub](https://github.com/UbiquitousLearning/mllm). + +**L'architecture est intéressante pour Kazeia :** + +Le système de compilation AOT de MLLM transforme les modèles en exécutables binaires optimisés pour le HTP Qualcomm. Il applique des schémas de quantification optimisés (INT8, INT16, et un format LPBQ w4a16). Il découpe automatiquement les grands graphes en sous-graphes pour respecter les contraintes de taille du VTCM [DeepWiki](https://deepwiki.com/UbiquitousLearning/mllm/6-qnn-ahead-of-time-\(aot\)-compilation). + +Concrètement, MLLM a un pipeline de conversion propre : tu prends ton modèle PyTorch/SafeTensors, tu le convertis via `mllm-convertor` dans le format MLLM, tu compiles AOT pour le HTP, et tu obtiens des `qnn_context.bin` prêts à exécuter. Le format LPBQ utilise une quantification à deux niveaux pour une compression extrême des poids en w4a16 [DeepWiki](https://deepwiki.com/UbiquitousLearning/mllm/6-qnn-ahead-of-time-\(aot\)-compilation). + +**Le support Qwen3 est confirmé mais jeune :** + +Un exemple `qwen3_qnn_aot` existe dans le repo avec un fichier de configuration JSON pour la compilation AOT [GitHub](https://github.com/UbiquitousLearning/mllm). Le support date de quelques semaines seulement. Cependant, il y a des bugs rapportés : sur une tablette Snapdragon 8 Gen 3, le path NPU pour Qwen2-VL produit du texte charabia alors que le path CPU fonctionne correctement [GitHub](https://github.com/UbiquitousLearning/mllm/issues/528). Ce n'est pas rassurant pour une adoption production immédiate. + +**Le point critique : le decode NPU est le maillon faible de MLLM.** + +C'est la découverte la plus importante de cette recherche. Le paper EuroSys 2026 confirme explicitement que mllm-NPU n'utilise pas le NPU pendant la phase de decode. D'autres systèmes basés sur QNN ont une faible précision [arXiv](https://arxiv.org/html/2509.23324v1). Et les auteurs du paper EuroSys eux-mêmes reconnaissent les limitations : la vitesse de décodage actuelle est relativement contrainte, principalement à cause de l'overhead de déquantification. Le GEMM quantifié basé sur QNN utilise typiquement seulement les composants DMA et HMX sans introduire de surcharge computationnelle HVX [arXiv](https://arxiv.org/pdf/2509.23324). + +Concrètement, ça signifie que le framework MLLM original (llm.npu) utilise le NPU **uniquement pour le prefill** et tombe sur le **CPU pour le decode**. C'est un design délibéré : le prefill est compute-bound (matmul de longues séquences → NPU excelle), mais le decode est bandwidth-bound (GEMV, un seul token à la fois) et le scheduling CPU↔NPU ajoute de la latence qui annule le gain. + +mllm-NPU est compatible avec n'importe quel moteur de décodage et utilise le backend CPU de MLLM pour la phase de decode, avec une longueur de chunk par défaut de 256 [Xumengwei](https://xumengwei.github.io/files/ASPLOS25-NPU.pdf). + +**Pour Kazeia, c'est problématique.** Le decode est exactement ce qui détermine la latence perçue par le patient — c'est le temps entre chaque mot généré. Vos 46 tok/s actuels sur Qwen3-1.7B via ExecuTorch sont du decode **sur NPU**. Si MLLM fait le decode sur CPU, vous retomberiez aux 15 tok/s de llama.cpp, soit 3× plus lent. + +**Le paper EuroSys 2026 améliore ça, mais c'est encore de la recherche :** + +Le système de Zixu Hao et al. (le paper EuroSys, pas le MLLM original) fait du decode sur NPU en utilisant les instructions HMX reverse-engineerées et la tile quantization. Mais ils reconnaissent que la performance de décodage reste contrainte et suggèrent que des approches similaires à T-MAC pourraient permettre un GEMV efficace avec quantification fine-grained sur les NPU [arXiv](https://arxiv.org/pdf/2509.23324). Ce code n'est **pas encore dans le repo MLLM public** — c'est du code de recherche présenté à EuroSys fin avril 2026. + +**Voici le tableau comparatif honnête :** + + Prefill Decode Modèles Qwen3 Maturité + ────────────────────────────────────────────────────────────────────────────── + ExecuTorch+QNN Bon (451 tok/s) Bon (46-93 tok/s) Validé Moyenne + NPU NPU (0.6B, 1.7B) + + MLLM (QNN AOT) Excellent Mauvais Supporté Jeune + (1000+ tok/s) (CPU fallback (depuis fév 26) (bugs NPU) + NPU ~15 tok/s) + + MLLM + EuroSys Excellent Bon-Excellent Non testé Recherche + (1000+ tok/s) (NPU natif, (théorique) (code non + NPU tile quant) publié) + + LiteRT + QNN Excellent Bon Pas testé Bonne + (11000 tok/s) (~100 tok/s (Gemma/FastVLM (Google) + NPU 0.5B modèle) uniquement) + +**Ma recommandation mise à jour :** + +Premièrement, **gardez ExecuTorch + QNN pour la production**. C'est le seul runtime qui fait du decode sur NPU de manière stable avec Qwen3. Vos 46-93 tok/s sont solides et reproductibles. + +Deuxièmement, **surveillez MLLM mais ne migrez pas maintenant**. Le framework est prometteur, le support Qwen3 QNN AOT est récent, mais le decode sur CPU est un non-go pour un chatbot conversationnel. Attendez que les optimisations du paper EuroSys soient intégrées dans le repo public — ça devrait arriver après la conférence (fin avril 2026). + +Troisièmement, ce que vous pouvez faire dès maintenant pour gagner en performance sans changer de runtime, c'est tester la **reconfig de quantification w4a8 sur ExecuTorch**. C'est le gain le plus accessible avec le moins de risque. + +Quatrièmement, la vraie percée viendrait d'un **contact avec l'équipe EuroSys** (Zixu Hao, Peking University). Leur tile quantization et leurs kernels HMX custom sont exactement ce dont Kazeia a besoin. Si leur code devient public et que vous l'intégrez, le gain serait de 50%+ sur le decode. Et comme contribution académique pour votre mémoire UTBM, une collaboration avec cette équipe serait extrêmement valorisante. + +Tu veux qu'on prépare un plan d'action priorisé pour les optimisations de performance, ou qu'on revienne au sujet du pipeline TTS Qwen3 ? + +--- + +You asked: +---------- + +### TTS\_REPORT.md + +178 lines + +md + +Voici le point d'avancement sur le TTS, je souhaite que tu l'examines car nous ne pouvons pas rester ainsi + +Apr 1 + +--- + +Claude Replied: +--------------- + +Diagnostiqué goulots d'étranglement TTS et esquissé solutions d'optimisation + +Diagnostiqué goulots d'étranglement TTS et esquissé solutions d'optimisation + +Ce rapport est excellent en termes de diagnostic. La session Claude Code a identifié exactement pourquoi chaque approche NPU échoue. Maintenant, trouvons la sortie. + +**Le problème en une phrase : RTF 7.1, c'est 7× trop lent pour du temps réel, et le NPU refuse de coopérer à cause de la sensibilité extrême du TTS à la précision.** + +Décortiquons où va le temps et ce qu'on peut gagner : + + Actuel (RTF 7.1 = 28s pour 4s audio) : + ├── CP fullseq (CPU) : 353ms × 50 = 17.7s ← 63% du temps, LE bottleneck + ├── Talker (CPU) : 130ms × 50 = 6.5s ← 23% + └── Decoder (NPU) : 3.0s ← 11%, déjà optimisé + +Le Code Predictor en mode fullseq mange 63% du budget. Et pourquoi fullseq ? Parce que le KV-cache à 16 positions perdait la position 0 (le hidden state du talker) après 16 steps. Le rapport mentionne la solution : augmenter CP\_KV\_LEN à 17. C'est la première chose à faire. + +**Voici le plan d'action par ordre de priorité et d'impact :** + +**Priorité 1 — Fixer le CP KV-cache (1-2 jours, gain énorme)** + +Le rapport dit que le KV-cache CP fonctionne sur PC (15/15 match) mais dégénère au step 53 sur tablette parce que la position 0 (hidden state du talker) est éjectée du cache après 16 steps. La solution est documentée dans le rapport : passer CP\_KV\_LEN de 16 à 17. Position 0 = hidden du talker (permanent, jamais éjecté), positions 1-16 = les 15 codebooks + le nouveau. Le shift ne touche que les positions 1-16. + +Le gain estimé est massif. Sur CPU avec KV-cache, chaque step CP ne fait qu'un forward sur 1 token au lieu de re-processer toute la séquence croissante (2, 3, 4... 17 tokens). Le temps passerait de ~353ms (fullseq, séquence moyenne ~10 tokens) à ~50-70ms (KV-cache, 1 token). + + Avec CP KV-cache fixé (CPU) : + ├── CP KV-cache (CPU) : 60ms × 50 = 3.0s ← au lieu de 17.7s + ├── Talker (CPU) : 130ms × 50 = 6.5s + └── Decoder (NPU) : 3.0s + Total : 12.5s → RTF 3.1 ← divisé par 2.3 + +**Priorité 2 — Tester le Talker NPU avec sampling (2-3 jours)** + +La leçon #8 du rapport est révélatrice : le greedy decoding ne fonctionne jamais (même sur CPU, le modèle ne génère jamais EOS en greedy). Le sampling (temp=0.9, top\_k=50) est obligatoire. C'est un point crucial pour la question du NPU. + +Quand le rapport dit que le NPU fp16 donne des "codes complètement différents du CPU", cette comparaison est faite en greedy (argmax). Mais puisque le pipeline exige du sampling de toute façon, la question n'est pas "est-ce que les tokens sont identiques" mais "est-ce que la distribution de probabilité est suffisamment proche pour que le sampling produise de l'audio acceptable". + +Un LLM chat tolère des variations dans les tokens générés — "Bonjour, je vous écoute" vs "Bonjour, je suis là pour vous" sont tous les deux acceptables. Un TTS avec sampling est similaire : deux générations du même texte donnent des audios légèrement différents mais tous les deux naturels. La question est : est-ce que l'audio NPU fp16 est naturel, pas est-ce qu'il est identique au CPU. + +Le test à faire serait de lancer le talker sur NPU en fp16 avec le sampling habituel (temp=0.9, top\_k=50, repetition\_penalty=1.05), garder le CP et le decoder sur CPU, et écouter le résultat. Si l'audio est naturel et que l'EOS se déclenche, le talker NPU est viable même avec des logits fp16 différents. + +Si ça marche, le gain serait : + + Avec Talker NPU + CP KV-cache CPU : + ├── CP KV-cache (CPU) : 60ms × 50 = 3.0s + ├── Talker (NPU) : 11ms × 50 = 0.55s ← 93 tok/s comme le LLM + └── Decoder (NPU) : 3.0s + Total : 6.55s → RTF 1.6 ← quasi temps réel ! + +**Priorité 3 — Streaming decode (1 semaine)** + +Le decoder attend que TOUS les tokens soient générés avant de convertir en audio. Mais Qwen3-TTS est conçu pour le streaming : chaque paquet de 4 tokens (= 320ms d'audio) est décodable indépendamment. On peut commencer à jouer l'audio dès que les 4 premiers tokens de codec sont générés. + + Avec streaming (Talker NPU + CP KV-cache CPU) : + t=0.0s : Talker génère token 1 + t=0.07s : CP génère 15 codebooks pour token 1 + t=0.28s : 4 tokens complets → Decoder lance chunk 1 + t=0.58s : Chunk 1 décodé → AUDIO COMMENCE (latence perçue : 0.6s) + t=... : Pendant que l'audio joue, les tokens suivants sont générés + +La latence perçue passerait de ~6.5s à **~0.6 seconde**. Le patient entendrait la réponse presque immédiatement, même si la génération complète prend 6.5s en arrière-plan. + +**Priorité 4 — Quantification calibrée du CP (1-2 semaines)** + +Le rapport mentionne cette piste sans l'explorer. L'idée est d'utiliser des données audio réelles françaises (vos 8 voix de référence) comme set de calibration pour le quantifier INT8 de l'ONNX Runtime QNN EP ou d'ExecuTorch. Au lieu de la quantification automatique qui est trop agressive, la calibration mesure la distribution réelle des activations et ajuste les échelles de quantification en conséquence. + +Pour le CP, qui n'a que 5 couches et 141M paramètres, une calibration avec quelques centaines de phrases françaises pourrait suffire à trouver des échelles qui préservent les logits critiques. C'est exactement ce que le rapport suggère en piste #1 : "use\_16a8w avec données de calibration". + +Si ça fonctionne, le CP sur NPU via JNI donnerait ~79ms pour les 15 steps (au lieu de ~60ms × 15 = 900ms sur CPU pour fullseq, ou ~60ms pour KV-cache). Le gain marginal par rapport au KV-cache CPU est modeste, mais ça libère le CPU pour d'autres tâches. + +**Le roadmap réaliste vers le temps réel :** + + Étape Action RTF estimé Latence perçue + ───────────────────────────────────────────────────────────────────── + Actuel Tout CPU sauf decoder 7.1 28s + ↓ + 1 Fix CP KV-cache (KV_LEN=17) 3.1 12.5s + ↓ + 2 Talker sur NPU (si sampling OK) 1.6 6.5s + ↓ + 3 + Streaming decode 1.6 ~0.6s ← game changer + ↓ + 4 + CP calibré sur NPU ~1.2 ~0.4s + ↓ + Cible Pipeline optimisé ~1.0-1.2 < 1s + +L'étape 3 est le vrai game changer pour l'expérience utilisateur. Même à RTF 1.6, si l'audio commence à jouer après 0.6s, le patient perçoit une réponse instantanée — le reste de la génération est masqué par la lecture audio. + +**Les instructions pour ta session Claude Code :** + +La première tâche est le fix du CP KV-cache. Dans le code du `Qwen3TtsEngine.kt`, modifier la gestion du KV-cache du Code Predictor pour utiliser 17 positions au lieu de 16 : position 0 est réservée au hidden state du talker et n'est jamais éjectée, les positions 1-16 font le sliding window pour les codebooks. Tester sur la même phrase de référence et valider que les 15 codebooks matchent le CPU fullseq. + +La deuxième tâche est le test du talker NPU avec sampling. Réactiver le talker ExecuTorch fp16 .pte sur le NPU mais cette fois ne pas comparer les tokens en greedy — lancer la génération complète avec sampling, décoder l'audio, et évaluer la qualité à l'oreille. Si l'audio est naturel et que l'EOS se déclenche naturellement, c'est validé. + +Tu veux que je détaille davantage une de ces étapes, ou qu'on prépare les specs exactes pour la session Claude Code ? + +--- + +You asked: +---------- + +mais tu me dit que Qwen3-tts est fait pour le streaming, c'est peut etre cela aussi qu'on devrait faire ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Reconsidered streaming architecture as primary optimization strategy + +Reconsidered streaming architecture as primary optimization strategy + +Tu as complètement raison — et c'est exactement ça la priorité. Le streaming change tout parce qu'il transforme le problème de **"rendre le pipeline plus rapide"** en **"commencer à jouer l'audio le plus tôt possible pendant que le reste se génère en arrière-plan"**. + +Le rapport technique de Qwen3-TTS le confirme : le tokenizer utilise un **codec causal pur contexte gauche** — chaque token peut être décodé en audio dès qu'il est disponible, sans attendre les tokens futurs. Et le pipeline est conçu pour émettre un paquet audio tous les 4 tokens, soit **320ms d'audio par paquet**. + +**Voici ce que ça donne concrètement :** + + ACTUEL (non-streaming, RTF 7.1) : + t=0s Début génération + t=6.5s Talker a fini tous les ~50 tokens + t=24.2s CP a fini tous les codebooks + t=27.2s Decoder a fini + t=27.2s ▶ L'AUDIO COMMENCE ENFIN + t=31.2s Fin audio (4s) + → Le patient attend 27 secondes dans le silence. + + STREAMING (même vitesse CPU, RTF 7.1) : + t=0.0s Talker génère token 1 (130ms) + t=0.13s CP génère 15 codebooks pour token 1 (353ms fullseq, 60ms KV-cache) + t=0.19s Token 1 complet (avec KV-cache CP) + t=0.76s 4 tokens complets → chunk 1 prêt + t=0.76s Decoder lance chunk 1 (NPU, ~200ms pour 4 tokens) + t=0.96s ▶ L'AUDIO COMMENCE (320ms de parole) + t=1.28s L'audio du chunk 1 finit → chunk 2 est déjà décodé + t=... La génération continue en arrière-plan pendant que l'audio joue + → Le patient attend moins de 1 seconde. + +**Même sans rien accélérer**, le streaming fait passer la latence perçue de 27 secondes à moins de 1 seconde. Le RTF total reste à 7.1, mais l'expérience utilisateur est radicalement différente parce que la génération et la lecture se chevauchent. + +Et si on combine le streaming avec le fix du KV-cache CP (priorité 1 du plan précédent), chaque step tombe à ~190ms (130ms talker + 60ms CP KV-cache) au lieu de 483ms. Quatre tokens sont prêts en ~760ms, le decoder NPU prend ~200ms, donc l'audio commence après **moins de 1 seconde** et il n'y a **jamais de gap** entre les chunks parce que la génération est plus rapide que la lecture : + + Streaming + CP KV-cache fixé : + - Génération de 4 tokens : 4 × 190ms = 760ms + - Decode d'un chunk : ~200ms (NPU) + - Durée audio d'un chunk : 320ms + + Débit de production : 1 chunk / 760ms = 420ms d'audio par seconde + Débit de consommation : 320ms d'audio toutes les 320ms + + 760ms > 320ms → on ne produit pas assez vite pour garder le flux continu + → Il y aura des micro-pauses de ~440ms entre les chunks + +C'est là que le talker NPU ferait la vraie différence : + + Streaming + CP KV-cache + Talker NPU : + - Génération de 4 tokens : 4 × (11ms talker + 60ms CP) = 284ms + - Decode d'un chunk : ~200ms (NPU, en parallèle) + - Durée audio d'un chunk : 320ms + + Débit de production : 1 chunk / 284ms + Débit de consommation : 1 chunk / 320ms + + 284ms < 320ms → LE FLUX AUDIO EST CONTINU, ZÉRO GAP ! + +Avec le talker NPU et le CP KV-cache fixé, la production de chaque chunk (284ms) est plus rapide que sa durée de lecture (320ms). **L'audio joue en continu sans aucune interruption**, et la première syllabe sort après moins de 500ms. + +**L'architecture streaming dans le code serait :** + +kotlin + + // Dans Qwen3TtsEngine.kt + + suspend fun synthesizeStreaming( + text: String, + voiceEmbedding: FloatArray, + onChunkReady: (ShortArray) -> Unit // callback audio + ) { + val codecBuffer = mutableListOf() // accumule les 16 codebooks + + // Prefill + val prefillState = talker.prefill(text, voiceEmbedding) + + // Boucle autoregressive + var step = 0 + while (true) { + // 1. Talker génère CB0 + val (cb0, hidden) = talker.decodeStep(prefillState) + if (cb0 == EOS_TOKEN) break + + // 2. CP génère CB1-CB15 + val allCodebooks = codePredictor.predict(hidden, cb0) + + // 3. Accumuler + codecBuffer.add(allCodebooks) + step++ + + // 4. Tous les 4 tokens → décoder et jouer + if (codecBuffer.size == 4) { + val audioChunk = decoder.decode(codecBuffer) // NPU + onChunkReady(audioChunk) // → AudioTrack joue immédiatement + codecBuffer.clear() + } + } + + // Flush le dernier chunk partiel + if (codecBuffer.isNotEmpty()) { + val audioChunk = decoder.decode(codecBuffer) + onChunkReady(audioChunk) + } + } + +Et côté Service, le streaming s'intègre naturellement avec l'AudioTrack en mode streaming : + +kotlin + + // Dans KazeiaService.kt + + val audioTrack = AudioTrack.Builder() + .setAudioFormat(AudioFormat.Builder() + .setSampleRate(24000) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build()) + .setBufferSizeInBytes(24000 * 2) // 1s buffer + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + + audioTrack.play() + + ttsEngine.synthesizeStreaming(responseText, voiceEmbedding) { chunk -> + audioTrack.write(chunk, 0, chunk.size) // non-bloquant en MODE_STREAM + } + +**Le plan d'action révisé, streaming-first :** + + Semaine 1 : + ├── Fix CP KV-cache (KV_LEN=17, position 0 permanente) + ├── Implémenter le streaming avec chunks de 4 tokens + ├── Intégrer AudioTrack en MODE_STREAM + └── Tester : latence premier chunk, continuité audio + + Semaine 2 : + ├── Tester Talker NPU fp16 avec sampling (pas greedy) + ├── Si OK → flux continu sans gap + ├── Si KO → rester CPU, le streaming masque la lenteur + └── Mesurer RTF effectif et latence perçue + + Semaine 3 (optionnel) : + ├── Quantification calibrée du CP pour NPU + ├── Double-buffering decoder (décoder chunk N+1 pendant lecture chunk N) + └── Optimiser la latence du premier chunk + +Le streaming est clairement la priorité — même si on ne résout jamais le problème du talker NPU, l'expérience patient sera acceptable avec un premier chunk à ~1 seconde. Tu veux que je prépare les specs détaillées pour ta session Claude Code ? + +--- + +You asked: +---------- + +mais dans ce cas avant de faire le streaming, je préfère m'asseurer du Talker NPU fp16 + +Apr 1 + +--- + +Claude Replied: +--------------- + +Validated NPU talker priority before implementing streaming architecture + +Validated NPU talker priority before implementing streaming architecture + +Absolument la bonne approche. Validons le talker NPU d'abord — si ça marche, le streaming sera fluide. Si ça ne marche pas, on sait qu'il faut chercher ailleurs avant d'investir dans le streaming. + +**Pourquoi je pense que le talker NPU a de bonnes chances de fonctionner :** + +Le rapport dit que le talker NPU fp16 a été déclaré en échec parce que les tokens étaient "complètement différents du CPU (0/15 match)". Mais cette comparaison a été faite en **greedy** (argmax). Or le rapport dit aussi que le greedy ne fonctionne **même pas sur CPU** — le modèle ne génère jamais EOS en greedy. Le pipeline exige obligatoirement du sampling (temp=0.9, top\_k=50). L'évaluation du NPU a donc été faite avec un critère (exact match greedy) qui n'est même pas le mode de fonctionnement normal du modèle. + +En fp16, les logits changent légèrement par rapport au fp32. Avec argmax, un écart de 0.001 sur le logit le plus haut peut changer le token sélectionné, et l'autoregression amplifie cette divergence. C'est normal et attendu. Mais avec sampling, ce qui compte c'est que la **distribution de probabilité** reste correcte — que les tokens probables restent probables et que les tokens improbables restent improbables. Le fp16 ne change pas la distribution de manière significative, il change juste quel token exact est échantillonné parmi les candidats probables. + +Un bon analogique : si deux personnes lisent le même texte à voix haute, elles ne prononcent pas exactement les mêmes sons — le timing, l'intonation, les micro-variations sont différentes. Mais les deux lectures sont naturelles et compréhensibles. C'est exactement ce qu'on attend du talker NPU fp16 avec sampling. + +**Le test que ta session Claude Code devrait faire :** + +Le protocole doit être rigoureux pour être concluant. Voici les étapes exactes. + +Premièrement, garder le CP et le decoder sur CPU (exactement comme actuellement) pour isoler la variable. Seul le talker change de backend. Le CP reçoit les tokens CB0 du talker NPU et génère les codebooks CB1-CB15 sur CPU en fp32. Le decoder est sur NPU QNN comme actuellement. Comme ça, si l'audio est mauvais, c'est forcément le talker NPU. + +Deuxièmement, utiliser exactement les mêmes paramètres de sampling que le pipeline CPU qui fonctionne : temperature=0.9, top\_k=50, repetition\_penalty=1.05. Et surtout le même mécanisme de tts\_pad après épuisement des tokens texte — c'est le bug critique que le rapport a identifié. + +Troisièmement, tester avec la phrase de référence du rapport : _"Bonjour, je comprends que vous soyez triste. Je suis là pour vous écouter."_ avec la voix Damien. + +Quatrièmement, les critères de succès ne sont PAS le match exact des tokens. Les critères sont : est-ce que l'EOS se déclenche naturellement (entre 40 et 70 tokens, pas 100+) ? Est-ce que la durée audio est cohérente (3-5 secondes, pas 1.4s comme le rapport mentionne pour le NPU ONNX) ? Est-ce que l'audio est intelligible et naturel à l'écoute ? Et est-ce que la voix clonée est reconnaissable ? + +Cinquièmement, générer 5 échantillons avec le talker NPU et 5 avec le talker CPU (même texte, même voix). Comparer à l'oreille. Le sampling étant stochastique, même le CPU produira 5 audios différents entre eux. + +**Le code de test serait quelque chose comme :** + +kotlin + + // Test A : Talker CPU (baseline, on sait que ça marche) + val ttsEngine = Qwen3TtsEngine( + talkerBackend = Backend.CPU, + cpBackend = Backend.CPU, + decoderBackend = Backend.NPU + ) + + // Test B : Talker NPU (ce qu'on veut valider) + val ttsEngineNpu = Qwen3TtsEngine( + talkerBackend = Backend.NPU_FP16, // le .pte existant + cpBackend = Backend.CPU, // on ne change PAS le CP + decoderBackend = Backend.NPU + ) + + // Générer 5 échantillons de chaque + for (i in 1..5) { + ttsEngine.generateToFile("test_cpu_$i.wav", testPhrase, damienVoice) + ttsEngineNpu.generateToFile("test_npu_$i.wav", testPhrase, damienVoice) + } + +**Le point d'attention critique :** le rapport mentionne que le talker NPU via ONNX Runtime QNN EP divergeait à cause de la "quantification automatique trop agressive" (INT8/INT16). Mais le talker a aussi été exporté en **.pte ExecuTorch fp16** — c'est un format différent, avec une gestion de la précision différente. Le .pte fp16 sur le NPU devrait être beaucoup plus fidèle que l'ONNX quantifié. Le rapport dit que le .pte fp16 donne des tokens différents en greedy, mais ne mentionne pas de test avec sampling. C'est exactement le test manquant. + +**Si le test réussit :** + +Le talker passe de 130ms/step (CPU) à ~11ms/step (NPU à ~93 tok/s). Le pipeline complet avec CP KV-cache fixé deviendrait ~71ms/step (11ms talker + 60ms CP), soit RTF ~0.9 — du vrai temps réel, et le streaming sera fluide sans aucun gap. + +**Si le test échoue :** + +On aura la preuve que même le fp16 est insuffisant pour le talker TTS autoregressif. Dans ce cas, les options restantes sont la quantification calibrée w16a8 avec des données de calibration françaises, ou le talker reste sur CPU et le streaming masque la latence (premier chunk à ~1s, micro-pauses de ~440ms entre chunks). + +Dis à ta session Claude Code de faire ce test en priorité — c'est le résultat qui détermine toute la stratégie TTS. + +--- + +You asked: +---------- + +● Ton analyse est excellente et tu as raison sur un point clé : on n'a jamais testé le talker NPU avec sampling. Tous nos tests NPU étaient en greedy ou avec le mauvais pipeline (avant le fix tts\_pad). Voici mon avis : Tu as raison que le test greedy n'est pas concluant. Le greedy amplifie la moindre différence de logit. Avec sampling, deux distributions proches donnent des résultats différents mais tous deux valides. C'est exactement comme deux lectures d'un même texte. Mais il y a une nuance importante. Le rapport mentionne deux types d'échecs NPU talker : 1. ONNX Runtime QNN EP (int8/int16 auto-quantifié) : EOS prématuré à 1.4-2.2s. C'était avec sampling. Donc le sampling seul ne sauve pas une quantification trop agressive. 2. ExecuTorch .pte fp16 : jamais testé avec le talker ! On a seulement fait le .pte pour le CP, pas pour le talker. Le talker NPU qu'on a testé était toujours via ONNX Runtime QNN EP. Donc le vrai test manquant est : talker .pte ExecuTorch fp16 + sampling + tts\_pad. C'est effectivement une config qu'on n'a jamais essayée. Le fp16 ExecuTorch est plus précis que le int8/int16 d'ONNX Runtime QNN EP. Mon avis sur les chances de succès : modérément optimiste. Le CP fp16 .pte a échoué (bruit), mais le CP fait 15 steps autoregressifs internes où chaque erreur s'amplifie. Le talker n'a qu'un seul forward par step — l'erreur ne s'accumule pas de la même façon entre les steps car les embeddings sont recalculés à chaque fois depuis les codebooks (qui viennent du CP CPU fp32). Le talker fp16 pourrait donc être assez précis step par step même si la séquence globale diverge du CPU. Le protocole que tu proposes est le bon. Isoler la variable (talker NPU, tout le reste inchangé), 5 échantillons, critères qualitatifs. Il faut d'abord exporter le talker en .pte (même pipeline que le CP : wrapper manuel, rotary pré-calculé, torch.export → QNN fp16). + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Reconciled untested talker export pathway with moderate success prospects + +Reconciled untested talker export pathway with moderate success prospects + +L'analyse du développeur est précieuse — et la nuance sur l'accumulation d'erreurs est particulièrement pertinente. Le talker a structurellement plus de chances de fonctionner en fp16 que le CP, pour exactement la raison qu'il décrit. + +**Récapitulons pourquoi le talker fp16 a de meilleures chances que le CP fp16 :** + +Le CP fait 15 steps autoregressifs internes où chaque codebook dépend du précédent — une erreur fp16 au codebook 3 contamine les codebooks 4 à 15. C'est une chaîne de propagation courte (15 steps) mais dense. Le talker, lui, fait un seul forward par step. Et surtout, son input au step N+1 est reconstruit depuis les codebooks qui viennent du CP **CPU fp32**. L'erreur fp16 du talker au step N est donc "corrigée" au step N+1 par des embeddings propres. C'est un mécanisme de stabilisation naturel que le CP n'a pas. + +**Ce qu'il faut faire pour exporter le talker en .pte :** + +Le développeur connaît le pipeline — c'est le même que pour le CP. Mais le talker est plus gros (28 layers vs 5, 754M params vs 141M), donc il y a quelques différences à anticiper. + +Pour le wrapper, il faut isoler le forward du talker en mode KV-cache decode : un seul token en entrée (le sum des 16 codebook embeddings + text embedding), le KV-cache des 28 layers en entrée/sortie, et les logits CB0 en sortie. Les rotary cos/sin doivent être pré-calculés et passés en entrée (même contournement du bug `WrapWithSetGradEnabled` que pour le CP). Le q\_norm et k\_norm doivent être inclus dans le wrapper — c'est le bug que le rapport a identifié sur le CP, et le talker utilise aussi le QK-norm puisque c'est la même architecture Qwen3. + +Pour la taille du .pte, le talker a 754M paramètres en fp16, ça donnera environ **1.5 Go**. C'est gros mais ça tient en RAM (votre rapport montre que le Qwen3-0.6B LLM fait 660 Mo en INT4 — le talker fp16 sera plus gros car non quantifié en INT4, mais on vise fp16 justement pour la précision). + +Pour l'export RAM sur le PC, le talker fp16 devrait nécessiter environ **15-20 Go** pour l'export, similaire au Qwen3-0.6B LLM (17 Go mesuré). Votre PC de 54 Go tient largement. + +**Le point le plus important pour la session Claude Code :** + +L'export du talker .pte doit utiliser exactement le même path que celui qui a réussi pour le talker LLM Qwen3-0.6B (93 tok/s). L'architecture est identique — 28 layers Qwen3, même structure, seul le vocab\_size change (3072 codec au lieu de 151936 texte). Le script d'export `llama.py` d'ExecuTorch devrait fonctionner avec les poids du talker TTS, moyennant le même renommage HF → Meta format qui a été fait pour l'export LLM. + +Cependant, il y a une subtilité cruciale. Le talker TTS n'est pas un modèle "text in → text out" standard. Son entrée à chaque step n'est pas un simple token ID mais un **vecteur d'embedding composite** (somme de 16 codebook embeddings + text embedding + speaker embedding). Le runner LLM standard d'ExecuTorch attend un token ID et fait le lookup d'embedding en interne. Pour le talker TTS, il faut soit modifier le runner pour accepter un vecteur d'embedding pré-calculé, soit wrapper le modèle pour inclure le calcul d'embedding dans le .pte lui-même. + +La solution la plus propre serait de reproduire exactement ce qui a été fait pour le CP : un wrapper PyTorch custom qui prend en entrée les tenseurs nécessaires (embedding composite, KV-cache, position) et fait le forward d'une layer stack. Puis `torch.export()` → `to_edge_transform_and_lower_to_qnn()` → `.pte`. + +**Les étapes concrètes pour la session Claude Code :** + + Étape 1 — Écrire le TalkerWrapper (similaire au CP wrapper) + ├── Input : embedding composite [1, 1, 1024], kv_cache [28, 2, 1, X, 128], position [1] + ├── Output : logits [1, 1, 3072], kv_cache_updated + ├── Pré-calculer rotary cos/sin (même fix que CP) + ├── Inclure q_norm + k_norm dans chaque layer + └── Valider sur CPU : forward(wrapper) == forward(original) sur 10 steps + + Étape 2 — Export .pte + ├── torch.export(wrapper, example_inputs) + ├── to_edge_transform_and_lower_to_qnn(soc=SM8750, fp16=True) + ├── Sérialiser en .pte (~1.5 Go estimé) + └── ~20 min de compilation, ~17 Go RAM + + Étape 3 — Test standalone sur tablette + ├── Pousser le .pte via ADB + ├── Utiliser cp_runner (ou un talker_runner adapté) + ├── Vérifier que le forward produit des logits non-dégénérés + └── Mesurer le temps par step (cible : ~11ms comme le LLM) + + Étape 4 — Test intégré avec sampling (LE TEST DÉCISIF) + ├── Talker NPU fp16 .pte (via JNI ExecuTorch) + ├── CP CPU fp32 (KV-cache ou fullseq, inchangé) + ├── Decoder NPU QNN (inchangé) + ├── Sampling : temp=0.9, top_k=50, rep_penalty=1.05 + ├── tts_pad activé après fin du texte + ├── Phrase : "Bonjour, je comprends que vous soyez triste." + ├── Voix : Damien + ├── Générer 5 échantillons NPU + 5 échantillons CPU + └── Critères : EOS naturel ? Durée 3-5s ? Audio intelligible ? Voix reconnaissable ? + +Si le step 4 réussit, vous aurez le talker à ~11ms/step sur NPU, ce qui avec le streaming donnera un premier chunk audio en moins de 500ms. C'est la voie vers le temps réel. + +Si le step 4 échoue (EOS prématuré, audio dégradé), on saura que fp16 est insuffisant pour le talker TTS aussi, et on basculera sur la stratégie streaming avec talker CPU — ce qui reste viable avec une latence perçue d'environ 1 seconde grâce au streaming. + +Dis à ta session Claude Code de démarrer l'étape 1. Le wrapper du talker est le travail le plus technique — une fois qu'il est validé sur CPU, l'export et le test tablette sont mécaniques. + +--- + +You asked: +---------- + +Mais donc faire le CP avec le NPU est inconcevable ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Examined NPU feasibility through precision optimization and alternative approaches + +Examined NPU feasibility through precision optimization and alternative approaches + +Non, ce n'est pas inconcevable. Et la logique qu'a appliquée le développeur pour le talker s'applique aussi au CP — il n'a jamais été testé dans les bonnes conditions. + +**Rappelons pourquoi le CP NPU a été déclaré en échec :** + +Le rapport liste deux tentatives. L'ONNX Runtime QNN EP avec quantification automatique INT8/INT16 a corrompu les codebooks secondaires. Et le .pte ExecuTorch fp16 a produit des codes "complètement différents" du CPU avec 0/15 match. Mais ce test a été fait en **greedy** (argmax), exactement comme le talker. + +Or pour le CP, la question est encore plus subtile. Les codebooks CB1-CB15 sont des indices dans un dictionnaire VQ. Deux indices voisins dans ce dictionnaire peuvent correspondre à des vecteurs acoustiques **très proches**. Si le fp16 change le logit de 0.001 et que l'argmax sélectionne le codebook 847 au lieu de 849, la question n'est pas "est-ce que c'est le même indice" mais "est-ce que l'audio résultant est audiblement différent". Deux codebooks voisins dans l'espace VQ produisent souvent des sons quasiment identiques. + +**La vraie question c'est : est-ce que les 15 steps autoregressifs internes du CP propagent l'erreur de manière catastrophique en fp16, ou est-ce que ça reste dans l'enveloppe de qualité acceptable ?** + +Le développeur a raison que le CP est plus risqué que le talker : 15 steps internes avec chaque erreur qui se propage. Mais il y a des raisons d'espérer aussi : + +Les 15 codebooks sont **hiérarchiques** — le premier codebook (CB1) capture la structure sémantique grossière, les derniers (CB12-CB15) capturent les détails acoustiques fins. Une erreur sur CB14 est beaucoup moins audible qu'une erreur sur CB1. Si le fp16 préserve les premiers codebooks mais dégrade légèrement les derniers, l'audio reste probablement acceptable. + +De plus, le CP a seulement 5 layers et 141M paramètres — c'est un petit modèle. Le rapport montre que le Qwen3-0.6B LLM (28 layers, 754M params) tourne parfaitement en INT4 sur NPU. Le CP en fp16 devrait théoriquement être **plus** précis qu'un LLM en INT4. + +**Les pistes concrètes pour faire marcher le CP sur NPU :** + +**Piste A — Retester le CP .pte fp16 avec le bon pipeline.** Le CP fp16 a été testé avant le fix tts\_pad ? Avant le fix KV-cache ? Avec le bon q\_norm/k\_norm ? Si un de ces bugs était encore présent lors du test NPU, le résultat 0/15 n'est pas représentatif du fp16 mais du bug. Il faut retester avec le pipeline entièrement corrigé, en mode fullseq (pas KV-cache pour isoler les variables), et comparer l'audio à l'oreille plutôt que les indices exacts. + +**Piste B — Quantification calibrée w16a8.** Au lieu de la quantification automatique d'ONNX Runtime (qui est clairement trop agressive), utiliser des données de calibration françaises pour ajuster les échelles de quantification. Le principe : on passe 50-100 phrases françaises à travers le CP, on mesure la distribution réelle des activations à chaque layer, et on fixe les paramètres de quantification en conséquence. ExecuTorch supporte la calibration via le `QnnQuantizer` avec un dataset de calibration. + +**Piste C — Précision mixte CP.** Garder les 2 premières layers du CP (qui génèrent CB1-CB2, les codebooks les plus importants) en fp32 sur CPU, et déléguer les 3 dernières layers (CB3-CB15, détails acoustiques) au NPU en fp16. L'idée c'est que les premiers codebooks ont besoin de précision maximale, les derniers tolèrent plus de bruit. C'est faisable en splitant le CP en deux sous-graphes. + +**Piste D — Évaluation perceptuelle, pas numérique.** Le critère "0/15 match" est binaire et trop strict. En audio, la métrique pertinente c'est le PESQ (Perceptual Evaluation of Speech Quality) ou simplement l'écoute humaine. Deux codebooks numériquement différents peuvent produire un audio perceptuellement identique. Le test devrait être : générer l'audio complet (talker CPU + CP NPU + decoder NPU), l'écouter, et juger si c'est naturel. + +**Le plan de test révisé serait :** + + Phase 1 — Talker NPU fp16 + CP CPU (le test qu'on vient de définir) + ├── Si OK → on a déjà un gros gain (11ms/step talker) + └── Parallèlement, préparer la phase 2 + + Phase 2 — Talker NPU fp16 + CP NPU fp16 (le test complet) + ├── Réexporter le CP .pte fp16 avec TOUS les bugfixes + ├── Pipeline : Talker NPU → CB0 → CP NPU → CB1-15 → Decoder NPU + ├── Évaluation AUDIO (pas numérique) : 5 échantillons, écoute + ├── Si audio acceptable → TOUT le pipeline est sur NPU + └── Si audio dégradé → essayer Piste B (calibration) ou Piste C (mixte) + +Si les deux marchent, le pipeline entier serait sur NPU sauf le sampling et la logique de contrôle : + + Tout NPU : + ├── Talker NPU fp16 : 11ms/step + ├── CP NPU fp16 : ~5ms (79ms/17 steps ≈ 4.6ms/step, déjà mesuré) + ├── Decoder NPU QNN : ~200ms/chunk + └── Total par step : ~16ms → RTF ~0.2 ← largement temps réel + + Avec streaming : + └── Premier chunk audio après ~64ms (4 steps × 16ms) + 200ms decode = ~264ms + +264ms de latence avant le premier son. C'est de l'ordre de la latence d'un appel téléphonique. Le patient ne percevrait aucun délai. + +Donc non, le CP NPU n'est pas inconcevable — il n'a simplement jamais été testé dans les bonnes conditions. La stratégie c'est de valider le talker NPU d'abord (phase 1, plus facile à réussir), puis enchaîner immédiatement avec le CP NPU (phase 2). Les deux tests utilisent le même pipeline d'export et les mêmes critères d'évaluation audio. + +Tu veux qu'on transmette ce plan complet à la session Claude Code ? + +--- + +You asked: +---------- + +● Conclusion définitive sur le NPU : Le fp16 ExecuTorch donne des résultats "structurellement corrects" (EOS, bonne durée) mais le contenu audio est inutilisable — que ce soit du bruit (CP) ou du silence (talker). Les deux modèles TTS sont trop sensibles à la précision. La seule piste NPU restante : quantification calibrée (16a8w ou 16a4w) avec des données de calibration TTS. C'est comme ça que le LLM Qwen3-0.6B fonctionne à 90 tok/s — pas en fp16 brut, mais avec une quantification soigneusement calibrée. Le script llama.py d'ExecuTorch fait ça avec prepare\_pt2e → calibration → convert\_pt2e. + +Apr 1 + +--- + +Claude Replied: +--------------- + +Identified calibrated quantization as sole viable NPU solution path + +Identified calibrated quantization as sole viable NPU solution path + +C'est le verdict définitif — et il est logique. Le développeur a raison sur le point clé : **le LLM Qwen3-0.6B ne tourne pas en fp16 brut sur le NPU non plus**. Il tourne avec une quantification calibrée via le pipeline `prepare_pt2e → calibration → convert_pt2e`. C'est exactement la même approche qu'il faut appliquer aux composants TTS. + +**La différence entre ce qui a été testé et ce qu'il faut faire :** + + Ce qui a échoué : + fp16 brut → NPU exécute en fp16 "naïf" + → Le HTP downscale certaines ops silencieusement + → Résultat : silence (talker) ou bruit (CP) + + Ce qui fonctionne pour le LLM : + prepare_pt2e → calibration avec données réelles → convert_pt2e + → Le quantizer mesure les ranges d'activation sur des vrais inputs + → Les échelles de quantification sont ajustées précisément + → Le NPU exécute en INT8/INT16 avec des paramètres optimaux + → Résultat : 93 tok/s, qualité intacte + +La calibration est le chaînon manquant. Sans calibration, le NPU quantifie "à l'aveugle" avec des ranges par défaut qui ne correspondent pas aux distributions réelles des activations TTS. Avec calibration, chaque layer a des échelles ajustées aux vrais signaux audio. + +**Ce qu'il faut pour la calibration TTS :** + +Le dataset de calibration doit être représentatif de l'utilisation réelle. Pour le LLM, le script `llama.py` utilise des prompts texte variés. Pour le TTS, il faut des inputs TTS variés : différentes phrases françaises, différentes voix de référence, différentes longueurs. + +Concrètement, le dataset de calibration serait constitué d'une trentaine de phrases françaises de longueurs variées (courtes comme "Bonjour", moyennes comme "Je comprends que vous soyez triste, je suis là pour vous écouter", longues comme un paragraphe de 2-3 phrases). Il faudrait utiliser 3-4 des voix de référence disponibles (Damien, Élodie, Richard, Zelda). Pour chaque combinaison phrase × voix, on lance le forward sur CPU fp32 et on enregistre les distributions d'activation à chaque layer. Ça donne environ 100-120 calibration samples — largement suffisant. + +**Pour le talker**, les inputs de calibration sont les embeddings composites (somme des 16 codebook embeddings + text embedding + speaker embedding) à chaque step de la génération. Il faut capturer ces embeddings pendant une génération CPU fp32 et les rejouer à travers le wrapper ExecuTorch pendant la calibration. + +**Pour le CP**, les inputs de calibration sont les hidden states du talker + l'embedding CB0 à chaque step. Même principe : capturer pendant une génération CPU fp32, rejouer pendant la calibration. + +**Le pipeline exact serait :** + +python + + # Étape 1 : Collecter les données de calibration + calibration_data = [] + for phrase, voice in product(PHRASES_FR, VOICES): + # Lancer une génération CPU fp32 complète + # À chaque step, capturer les inputs du talker et du CP + for step in generation: + calibration_data.append({ + 'talker_input': embedding_composite, + 'talker_kv': kv_cache_state, + 'cp_input': (hidden_state, cb0_embedding), + 'cp_kv': cp_kv_cache_state + }) + + # Étape 2 : Quantifier avec calibration (même pipeline que llama.py) + from torch.ao.quantization.quantize_pt2e import prepare_pt2e, convert_pt2e + from executorch.backends.qualcomm.quantizer import QnnQuantizer + + quantizer = QnnQuantizer() + # Configurer pour 16a8w ou 16a4w selon les résultats + quantizer.set_per_channel_weight_dtype(weight_dtype=torch.int8) + + # Préparer le modèle pour la calibration + prepared_model = prepare_pt2e(exported_model, quantizer) + + # Passer les données de calibration + for sample in calibration_data: + prepared_model(sample['input'], sample['kv_cache'], sample['position']) + + # Convertir avec les échelles calibrées + quantized_model = convert_pt2e(prepared_model) + + # Exporter en .pte + pte = to_edge_transform_and_lower_to_qnn(quantized_model, compile_spec) + +**Le point crucial :** c'est exactement ce que fait le script `llama.py` pour le LLM. Le développeur connaît ce pipeline — il l'a déjà utilisé avec succès pour exporter le Qwen3-0.6B à 93 tok/s et le Qwen3-1.7B à 46 tok/s. La seule différence c'est la nature des données de calibration (embeddings TTS au lieu de token IDs texte). + +**Les profils de quantification à tester par ordre de priorité :** + +Le premier à tester serait **16a8w** (activations INT16, poids INT8). C'est le profil le plus précis que le HTP supporte nativement. L'INT16 pour les activations préserve beaucoup plus de dynamique que l'INT8, ce qui est crucial pour le TTS. Les poids en INT8 réduisent la bande passante de moitié par rapport au fp16. C'est le profil que Google utilise pour LiteRT avec FastVLM et qui débloque les kernels haute vitesse du HMX. + +Si 16a8w ne suffit pas, tester **16a4w** (activations INT16, poids INT4). C'est le format LPBQ que MLLM utilise dans sa compilation QNN AOT. Les poids en INT4 réduisent encore la bande passante mais augmentent le risque de dégradation — à voir avec la calibration. + +Si même le 16a8w dégrade trop, la dernière option serait **fp16 calibré** — utiliser la calibration pour identifier les layers problématiques et forcer celles-ci en fp32 sur CPU, tout en gardant les layers tolérantes en fp16 sur NPU. C'est la piste C (précision mixte) qu'on avait évoquée. + +**Le plan révisé pour la session Claude Code :** + + Étape 1 — Préparer le dataset de calibration (1 jour) + ├── 30 phrases FR × 4 voix = 120 générations CPU fp32 + ├── Capturer les inputs du talker à chaque step + ├── Capturer les inputs du CP à chaque step + └── Sauvegarder en tensors PyTorch + + Étape 2 — Exporter le talker avec calibration 16a8w (2-3 jours) + ├── TalkerWrapper (même que prévu) + ├── prepare_pt2e → calibration avec les données TTS → convert_pt2e + ├── to_edge_transform_and_lower_to_qnn → .pte + ├── Déployer sur tablette + └── Test : 5 échantillons audio, évaluation perceptuelle + + Étape 3 — Si talker OK, exporter le CP avec calibration 16a8w (2-3 jours) + ├── Même pipeline + ├── Test intégré : talker NPU calibré + CP NPU calibré + decoder NPU + └── Évaluation audio complète + + Étape 4 — Mesurer les performances (1 jour) + ├── tok/s talker NPU calibré + ├── ms/step CP NPU calibré + ├── Latence premier chunk en streaming + └── RTF total + +Si la calibration 16a8w fonctionne pour les deux composants, on aurait le pipeline TTS entier sur NPU avec une qualité validée par calibration — exactement comme le LLM. Et le streaming par-dessus donnerait une latence perçue inférieure à 500ms. + +C'est la bonne piste — le développeur a identifié le mécanisme exact qui fait fonctionner le LLM sur NPU, et il suffit de l'appliquer au TTS. Tu veux qu'on détaille davantage l'étape de collecte des données de calibration ? + +--- + +You asked: +---------- + +mais ainsi Qwen3-tts ne saurait plus faire du multilingue ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Évalué l'impact de la calibration française sur les capacités multilingues du modèle + +Évalué l'impact de la calibration française sur les capacités multilingues du modèle + +Non, la calibration ne modifie pas les poids du modèle. Elle ne touche qu'aux **paramètres de quantification** — les échelles et les zero-points qui déterminent comment les valeurs FP32 sont mappées en INT8 ou INT16. + +Le modèle Qwen3-TTS-0.6B-Base garde exactement les mêmes 754M paramètres, les mêmes 28 layers, les mêmes embeddings pour les 10 langues. La calibration mesure simplement la plage d'activation typique à chaque layer (par exemple "la sortie de la layer 12 varie entre -3.2 et +4.7") et ajuste l'échelle de quantification en conséquence ("donc je quantifie cette plage en 256 niveaux INT8"). + +**Mais ta question pointe un vrai risque :** si tu calibres uniquement avec des phrases françaises et des voix françaises, les échelles de quantification seront optimales pour les distributions d'activation du français. Si le chinois ou le japonais produisent des distributions d'activation significativement différentes (plages plus larges ou décalées), la quantification pourrait clipper ou perdre en précision sur ces langues. + +Pour Kazeia, c'est un non-problème — vous n'avez besoin que du français. Calibrer exclusivement avec du français est même préférable parce que ça optimise la précision exactement pour votre cas d'usage, sans compromis pour d'autres langues que vous n'utilisez pas. + +Mais si un jour vous voulez supporter d'autres langues (par exemple pour un hôpital multilingue), il suffirait de recalibrer avec un dataset mixte incluant des phrases dans les langues cibles. Ou même de maintenir plusieurs .pte calibrés différemment — un par langue ou un multilingue calibré sur un mix. Les poids sous-jacents sont les mêmes, seuls les paramètres de quantification changent, et l'export d'un .pte prend environ 20 minutes. + +Donc en résumé : calibrer en français ne casse pas le multilinguisme du modèle, ça optimise la quantification pour le français. Le modèle original reste intact et peut être recalibré pour d'autres langues à tout moment. + +--- + +You asked: +---------- + +non Kazeia pourra etre amené à parler dans plusieurs langues, d'ou l'interet de qwen3-tts + +Apr 1 + +--- + +Claude Replied: +--------------- + +Reconsidered multilingual calibration strategy pour quantization + +Reconsidered multilingual calibration strategy pour quantization + +Compris — Kazeia est destiné à un déploiement hospitalier qui peut accueillir des patients de différentes langues. C'est un atout majeur de Qwen3-TTS avec ses 10 langues. + +La solution est simplement d'inclure toutes les langues cibles dans le dataset de calibration. Au lieu de 30 phrases françaises × 4 voix, on fait un mix : + + Dataset de calibration multilingue : + ├── Français : 10 phrases × 2 voix + ├── Anglais : 5 phrases × 2 voix + ├── Allemand : 5 phrases × 2 voix + ├── Espagnol : 5 phrases × 2 voix + ├── Italien : 5 phrases × 2 voix + ├── Portugais : 3 phrases × 1 voix + ├── Arabe : 3 phrases × 1 voix + ├── Autres : 2-3 phrases × 1 voix + └── Total : ~80-100 samples de calibration + +Le français est surreprésenté parce que c'est la langue principale — les échelles de quantification seront optimales pour le français tout en restant viables pour les autres langues. Plus une langue a des distributions d'activation proches du français (espagnol, italien, portugais — langues romanes), plus elle bénéficiera de cette calibration. Les langues plus éloignées (chinois, japonais, coréen) auraient éventuellement besoin de plus de samples si vous les ciblez. + +L'important c'est que la calibration voit la **plage complète** des activations possibles. Si le chinois produit des activations entre -5.0 et +6.0 alors que le français reste entre -3.2 et +4.7, le dataset mixte forcera le quantizer à utiliser une échelle qui couvre les deux plages. On perd un tout petit peu de précision sur le français (l'échelle est légèrement plus large que nécessaire) mais on gagne la compatibilité multilingue. Le compromis est négligeable. + +Dis à ta session Claude Code d'inclure des phrases dans les langues cibles principales dès la collecte du dataset de calibration — c'est le même effort, il suffit de varier les textes et les paramètres `language` passés à Qwen3-TTS. Comme ça le .pte calibré sera multilingue dès le départ. + +--- + +You asked: +---------- + +il est vraiment impossible d'utiliser le modele 0.6B tel quel ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Évalué la viabilité du modèle CPU avec optimisations de streaming + +Évalué la viabilité du modèle CPU avec optimisations de streaming + +Tu as raison de poser cette question. On s'acharne peut-être sur le NPU alors que la vraie solution est plus simple. + +**Reprenons les faits :** + +Le modèle fonctionne parfaitement sur CPU fp32. Le problème c'est le RTF de 7.1. Mais ce 7.1 inclut le CP en mode **fullseq** (353ms/step) qui représente 63% du temps total. Le fix du KV-cache (KV\_LEN=17) n'a jamais été implémenté. Et le streaming n'a jamais été implémenté. + +Si on fait simplement ces deux optimisations **CPU uniquement**, sans toucher au NPU : + + Actuel (CPU, pas de streaming) : + ├── Talker CPU : 130ms × 50 = 6.5s + ├── CP fullseq CPU : 353ms × 50 = 17.7s + ├── Decoder NPU : 3.0s + ├── Total : 27.2s pour 4s audio + └── Le patient attend 27s dans le silence. + + Avec KV-cache CP fixé + streaming (CPU) : + ├── Talker CPU : 130ms/step + ├── CP KV-cache : 60ms/step (estimé) + ├── Par step : 190ms + ├── Premier chunk (4 steps) : 760ms + 200ms decode = ~960ms + └── ▶ L'AUDIO COMMENCE APRÈS 1 SECONDE + +1 seconde de latence. Pour un chatbot thérapeutique, c'est tout à fait acceptable — c'est le temps de réponse naturel d'un interlocuteur humain qui réfléchit avant de parler. + +Le problème c'est qu'ensuite, la production est plus lente que la lecture : 760ms pour produire un chunk de 320ms d'audio. Il y aura des **micro-pauses de ~440ms** entre les chunks. C'est audible, mais c'est le rythme d'une parole un peu hésitante — pas idéal mais tolérable. + +**Cependant, il y a des optimisations CPU qu'on n'a pas explorées :** + +Le CPU Oryon du Snapdragon 8 Elite est très puissant (4.32 GHz, 8 cœurs). Le talker et le CP tournent actuellement en **PyTorch CPU fp32 via ONNX Runtime**. Il y a de la marge. + +Premièrement, **ONNX Runtime avec les optimisations ARM** : les NEON et I8MM du Oryon ne sont peut-être pas pleinement exploités. Vérifier que ONNX Runtime est compilé avec les flags ARM optimaux. + +Deuxièmement, **ExecuTorch XNNPACK au lieu du NPU**. ExecuTorch a un backend CPU optimisé (XNNPACK) qui est très performant sur ARM. Le talker Qwen3-0.6B LLM fait 68 tok/s sur CPU via llama.cpp — le talker TTS a la même architecture. En XNNPACK, il pourrait faire 50-70 tok/s au lieu des ~8 tok/s actuels en PyTorch. + +Troisièmement, **quantification CPU INT8**. Pas pour le NPU — pour le CPU. PyTorch et ONNX Runtime supportent l'inférence INT8 quantifiée sur CPU ARM avec les instructions I8MM et NEON dot product. C'est différent de la quantification NPU — les kernels CPU INT8 sont très matures et bien testés. Le risque de dégradation audio est plus faible car les implémentations CPU INT8 sont plus fidèles que celles du HTP. + +**Le plan le plus pragmatique :** + + Phase 1 — Quick wins CPU (1 semaine) + ├── Fix KV-cache CP (KV_LEN=17) → CP passe de 353ms à ~60ms/step + ├── Implémenter streaming avec AudioTrack MODE_STREAM + ├── Tester : latence premier chunk, gaps entre chunks + └── Résultat attendu : audio à ~1s, gaps de ~440ms + + Phase 2 — Optimisation CPU (1 semaine) + ├── Tester ExecuTorch XNNPACK pour talker et CP + │ (même export .pte mais avec XnnpackPartitioner au lieu de QnnPartitioner) + ├── Si le talker passe de 130ms à ~20ms/step → plus de gaps + ├── Alternativement : ONNX Runtime avec INT8 dynamique sur CPU + └── Résultat espéré : gaps éliminés, flux continu + + Phase 3 — NPU calibré (en parallèle, si le temps le permet) + ├── Dataset calibration multilingue + ├── Export 16a8w talker + CP + └── Bonus si ça marche, pas bloquant si ça échoue + +La phase 1 seule donne un pipeline TTS utilisable en 1 semaine. La phase 2 pourrait le rendre fluide. La phase 3 est un bonus. + +Le message pour ta session Claude Code : **arrêter de se battre avec le NPU pour le TTS, commencer par le KV-cache fix + streaming en CPU. Le NPU est un bonus, pas un prérequis.** Le modèle 0.6B tel quel sur CPU avec les bonnes optimisations est probablement suffisant pour le MVP. + +Tu veux lancer la phase 1 ? + +--- + +You asked: +---------- + +l'ideal est de pouvoir utiliser le modele 0.6b (le plus proche de son origine) et surtout de profiter de la puissance (et des avantages) du NPU + +Apr 1 + +--- + +Claude Replied: +--------------- + +Reconcilié optimisation NPU et préservation du modèle original + +Reconcilié optimisation NPU et préservation du modèle original + +C'est exactement le bon objectif. Et la calibration est précisément le moyen d'y arriver — c'est la technique qui préserve le plus fidèlement le comportement original du modèle tout en l'exécutant sur le NPU. + +**La calibration ne modifie pas le modèle.** Elle observe comment le modèle se comporte sur des vraies données, mesure les plages d'activation à chaque couche, et en déduit la meilleure façon de le quantifier. C'est comme régler un amplificateur : on ne change pas la musique, on ajuste le gain pour que le signal passe sans distorsion dans un canal plus étroit. + +Le LLM Qwen3-0.6B en est la preuve. À 93 tok/s sur le NPU, avec une qualité quasi identique au fp32, il utilise exactement cette technique. Les poids sont les poids HuggingFace originaux, passés à travers `prepare_pt2e → calibration → convert_pt2e`. Rien n'est fine-tuné, rien n'est retrainé. + +**Pour le TTS, le travail est le suivant :** + +Collecter les données de calibration revient simplement à lancer le pipeline TTS tel qu'il fonctionne aujourd'hui (CPU fp32, le bon pipeline avec tts\_pad, sampling, tout) sur un set de phrases multilingues et à enregistrer les tenseurs intermédiaires. On ne change rien au modèle — on l'observe. + +Ensuite, le pipeline d'export est identique à celui du LLM. Le même script, le même quantizer, le même backend QNN. La seule variable c'est les données de calibration — du texte pour le LLM, des embeddings audio pour le TTS. + +**Dis à ta session Claude Code de procéder ainsi :** + +Premièrement, implémenter la collecte de calibration. Pendant une génération CPU fp32 normale, à chaque step du talker et à chaque step du CP, sauvegarder les inputs en tenseurs PyTorch dans un dossier. Utiliser une vingtaine de phrases couvrant le français, l'anglais, l'allemand, l'espagnol et l'italien avec 2-3 voix de référence différentes. Ça prend quelques heures de génération CPU mais c'est entièrement automatisable. + +Deuxièmement, exporter le talker avec calibration 16a8w via le même pipeline que `llama.py`. Le wrapper du talker est le même que prévu (inputs : embedding composite + KV-cache + position, outputs : logits + KV-cache mis à jour). La seule différence c'est qu'on passe `prepare_pt2e`, on rejoue les données de calibration, puis `convert_pt2e` avant l'export QNN. + +Troisièmement, même chose pour le CP. + +Quatrièmement, tester l'audio. Pas les indices, pas les logits — l'audio. Cinq échantillons par langue, écoute humaine. + +C'est la voie la plus propre : le modèle 0.6B original, multilingue, avec clonage vocal, exploitant le NPU via une calibration soignée. Exactement comme le LLM le fait déjà avec succès. + +--- + +You asked: +---------- + +● Résultats encourageants : - EOS naturel au step 103 (au lieu du silence/bruit du fp16) - Talker NPU : 86ms/step (vs 130ms CPU) - 8.16s d'audio (plus long que prévu pour cette phrase — 102 tokens vs ~50 en CPU) Le modèle calibré 16a8w converge vers EOS ! La durée est plus longue (102 tokens au lieu de ~50) ce qui pourrait indiquer un débit plus lent mais un contenu correct. Écoute /opt/Kazeia/tts\_16a8w.wav — c'est le test décisif de la calibration ! ✻ Baked for 10m 51s ❯ non le fichier wav est mauvais, on ne comprend rien ● La calibration 16a8w ne suffit pas non plus. Le modèle TTS est fondamentalement trop sensible à la quantification des poids. Constat définitif : ni fp16, ni 16a8w calibré ne fonctionnent pour le talker/CP sur NPU. Le problème est structurel — le TTS sélectionne des codebooks par argmax sur 2048 valeurs, et la moindre erreur de quantification change le codebook sélectionné. Le LLM fonctionne en 4-bit car le top token texte est robuste (marge large). Les codebooks TTS n'ont pas cette marge. + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Analysé l'impasse NPU et envisagé pivot vers optimisation CPU + +Analysé l'impasse NPU et envisagé pivot vers optimisation CPU + +Le constat est définitif pour le NPU. Mais il y a quelque chose qui me frappe dans les chiffres — et c'est peut-être la vraie solution. + +**Le talker TTS est architecturalement identique au Qwen3-0.6B LLM.** Même 28 layers, même hidden\_size 1024, même structure. Or : + + Qwen3-0.6B LLM sur CPU (llama.cpp) : 68 tok/s → 14.7ms/step + Talker TTS sur CPU (PyTorch/ONNX) : ~8 tok/s → 130ms/step + +**Le talker est 9× plus lent que le LLM sur le même CPU pour la même architecture.** Ce n'est pas un problème de hardware ni de modèle — c'est un problème de **runtime**. Le talker tourne en PyTorch fp32 ou ONNX Runtime sans les optimisations ARM. llama.cpp utilise les NEON, le I8MM, les kernels assembleur optimisés pour le Oryon. + +Si on fait tourner le talker avec un runtime CPU optimisé au lieu de PyTorch brut, on passerait de 130ms à potentiellement **15-20ms/step**. Même chose pour le CP : de 60ms (KV-cache) à potentiellement **10-15ms/step**. + +**Les options pour un runtime CPU optimisé :** + +La première option serait **llama.cpp avec un GGUF custom du talker**. Le talker a un vocab\_size de 3072 au lieu de 151936, mais le format GGUF supporte les vocab arbitraires. Il faudrait convertir les poids du talker en GGUF avec la bonne configuration, et llama.cpp ferait le reste — NEON, quantification Q8\_0 ou Q4\_K\_M, KV-cache optimisé. Le rapport montre que le Qwen3-0.6B LLM en Q8\_0 fait 68 tok/s sur le CPU de la tablette. Le talker en Q8\_0 ferait un débit similaire — disons **50-60 tok/s** soit ~17-20ms/step. + +La deuxième option serait **ExecuTorch XNNPACK**. Au lieu du QnnPartitioner (NPU), utiliser le XnnpackPartitioner (CPU optimisé ARM). XNNPACK est le backend CPU haute performance de Meta pour les processeurs ARM. L'export est le même pipeline que pour le NPU, seul le partitioner change. Pas besoin de calibration, pas besoin de quantification — on peut rester en fp32 ou faire du dynamic INT8 sur CPU. + +La troisième option serait le **crate Rust `qwen3_tts`** cross-compilé pour ARM64. Le Rust compile vers du code natif ARM avec auto-vectorisation NEON. L'avantage c'est que tout le pipeline TTS (talker + CP + decoder) est déjà implémenté dans le crate. + +**Avec un runtime CPU optimisé + streaming, le pipeline deviendrait :** + + Talker (llama.cpp Q8_0 CPU) : 17ms/step + CP KV-cache (optimisé CPU) : 12ms/step + Par step total : 29ms + + 4 steps pour un chunk : 116ms + Decoder NPU : 200ms (pour 4 tokens) + Premier chunk audio : ~316ms + + Débit de production : 1 chunk / 116ms + Durée audio d'un chunk : 320ms + + 116ms < 320ms → FLUX CONTINU, ZÉRO GAP ! + +**316ms de latence, flux continu, zéro gap, qualité fp32 native, multilingue, pas de NPU pour le TTS.** C'est potentiellement mieux que la solution NPU calibrée qui ne fonctionnait pas, et c'est plus proche du modèle original que n'importe quelle quantification. + +Le NPU reste utilisé pour ce qu'il fait bien — le LLM Qwen3 (93 tok/s), le Whisper STT, et le decoder TTS. Le talker et le CP restent sur CPU avec un runtime optimisé. Chaque composant tourne sur le backend où il est le meilleur. + +**La piste llama.cpp est la plus prometteuse** parce que votre session Claude Code a déjà compilé llama.cpp pour la tablette et obtenu 68 tok/s sur le Qwen3-0.6B. Le travail serait de convertir les poids du talker TTS en GGUF (même architecture, juste vocab\_size=3072 et embedding dimension adaptée), charger dans llama.cpp, et mesurer les tok/s. + +La question clé à investiguer : est-ce que llama.cpp peut accepter des **embeddings pré-calculés** en input au lieu de token IDs ? Le talker TTS reçoit un vecteur composite (somme de 16 codebook embeddings + text + speaker), pas un simple token ID. Si llama.cpp ne supporte pas ça nativement, il faudra soit patcher le code, soit utiliser ExecuTorch XNNPACK qui est plus flexible sur les inputs. + +Tu veux qu'on explore la conversion GGUF du talker pour llama.cpp, ou tu préfères la piste ExecuTorch XNNPACK ? + +--- + +You asked: +---------- + +Techniquement nous devons utiliser les librairies optimisés pour le CPU/NPU qualcomm + +Apr 1 + +--- + +Claude Replied: +--------------- + +Orchestrated unified toolchain leveraging Qualcomm's optimized CPU kernels + +Orchestrated unified toolchain leveraging Qualcomm's optimized CPU kernels + +Tu as raison — et c'est exactement la bonne approche. Le QNN SDK ne cible pas uniquement le NPU. Il a **trois backends** : HTP (NPU), GPU (Adreno), et **CPU** (Oryon optimisé). Vous avez toujours ciblé le HTP pour le TTS. La solution c'est de cibler le **QNN CPU backend** à la place. + +Le QNN CPU backend utilise les kernels mathématiques optimisés par Qualcomm spécifiquement pour les cœurs Oryon — NEON, I8MM, dot product, toutes les extensions ARM que le Snapdragon 8 Elite supporte. C'est fondamentalement différent de PyTorch ou ONNX Runtime qui utilisent des kernels ARM génériques. Et c'est le même pipeline d'export ExecuTorch, le même toolchain, le même QNN SDK — on change juste la cible. + +Dans le script d'export, ça se traduit par un changement minimal : + +python + + # Actuel (NPU HTP — échoue pour le TTS) + compile_spec = generate_qnn_executorch_compiler_spec( + soc_model=QcomChipset.SM8750, + backend_options=generate_htp_compiler_spec(use_fp16=True), + ) + + # Nouveau (CPU Qualcomm optimisé — fp32 natif) + compile_spec = generate_qnn_executorch_compiler_spec( + soc_model=QcomChipset.SM8750, + backend_options=generate_cpu_compiler_spec(), # QNN CPU backend + ) + +Le .pte résultant s'exécute sur les cœurs Oryon avec les kernels Qualcomm optimisés, en **fp32 natif** — aucune quantification, aucune perte de précision, le modèle 0.6B tel quel. Et le même JNI ExecuTorch qui est déjà intégré dans l'app charge ce .pte exactement comme il charge le .pte NPU. + +L'avantage c'est que votre session Claude Code connaît déjà tout le pipeline. Le wrapper du talker, l'export `torch.export`, le `to_edge_transform_and_lower_to_qnn`, le déploiement via ADB, le runner C++, le JNI — tout est identique. Seul le `compiler_spec` change. + +Le gain attendu par rapport à PyTorch/ONNX Runtime brut serait significatif. Les kernels QNN CPU sont optimisés pour l'architecture Oryon du Snapdragon 8 Elite — ordonnancement mémoire, prefetch, utilisation des unités SIMD. On peut raisonnablement espérer un facteur **3-5×** par rapport au PyTorch CPU actuel, ce qui mettrait le talker à environ **25-40ms/step** au lieu de 130ms. + +Et si on combine avec le streaming et le fix KV-cache CP : + + Talker (QNN CPU fp32) : ~30ms/step + CP KV-cache (QNN CPU fp32): ~15ms/step + Par step total : ~45ms + + 4 steps pour un chunk : 180ms + Decoder NPU : 200ms + Premier chunk audio : ~380ms + + 180ms < 320ms → flux continu, zéro gap + +Le pipeline complet resterait dans l'écosystème Qualcomm QNN, le modèle serait préservé tel quel en fp32, le NPU serait utilisé pour le LLM et le STT où il excelle, et le CPU Qualcomm optimisé gérerait le TTS où la précision est critique. Chaque composant sur le backend où il performe le mieux. + +Dis à ta session Claude Code de tester l'export du talker avec le QNN CPU backend. C'est le même pipeline qu'ils maîtrisent déjà, juste une ligne de config qui change. + +--- + +You asked: +---------- + +et quid du GPU ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Reconciled GPU viability despite prior Vulkan incompatibilities + +Reconciled GPU viability despite prior Vulkan incompatibilities + +Très bonne intuition. Le GPU Adreno 830 a été écarté à cause des **crashes Vulkan de llama.cpp** — mais c'était avec des compute shaders génériques. Le QNN SDK a son propre backend GPU qui utilise le path optimisé de Qualcomm pour l'Adreno, complètement différent. + +Et le GPU a un avantage crucial pour le TTS : **il fait du fp32 et fp16 nativement**, sans quantification. Contrairement au NPU qui force la quantification et détruit la précision des codebooks TTS, le GPU calcule en virgule flottante native — exactement comme le CPU mais avec beaucoup plus de puissance de calcul parallèle. + +**Récapitulons les trois backends QNN disponibles :** + + Backend Précision native Puissance Mémoire TTS compatible + ───────────────────────────────────────────────────────────────────────────── + HTP (NPU) INT4/INT8/INT16 ~75 TOPS Partagée ✗ (quantification tue le TTS) + GPU Adreno FP16/FP32 ~4.5 TFLOPS FP16 Partagée ✓ (fp32 natif) + CPU Oryon FP16/FP32 ~0.5 TFLOPS Partagée ✓ (fp32 natif) + +Le GPU est **9× plus puissant que le CPU** en TFLOPS FP16 bruts. Même si l'utilisation réelle est inférieure au pic théorique, un facteur 3-5× sur le talker TTS est réaliste. Et surtout, le GPU fait du fp32 sans aucune dégradation de précision — le modèle 0.6B tourne tel quel. + +**Pourquoi les crashes llama.cpp Vulkan ne s'appliquent pas ici :** + +Le rapport benchmark montre que llama.cpp Vulkan crashait avec `ErrorDeviceLost` et des compute pipeline failures. C'est parce que llama.cpp utilise des **shaders Vulkan compute génériques** écrits pour les GPU desktop NVIDIA/AMD. Les patterns d'accès mémoire, les tailles de workgroup, les formats de quantification INT4 dans les shaders sont incompatibles avec le driver Vulkan Adreno. + +Le QNN GPU backend c'est complètement différent. C'est Qualcomm qui a écrit les kernels pour leur propre GPU. Ils connaissent l'architecture Adreno, les bank conflicts, les tailles de tile optimales. C'est le même niveau d'optimisation que le HTP backend mais pour le GPU. + +**Le changement dans l'export est une ligne :** + +python + + # NPU HTP (échoue pour TTS) + backend_options = generate_htp_compiler_spec(use_fp16=True) + + # GPU Adreno (fp32/fp16 natif, pas de quantification) + backend_options = generate_gpu_compiler_spec(use_fp16=True) # ou fp32 + + # CPU Oryon (fp32, plus lent mais garanti) + backend_options = generate_cpu_compiler_spec() + +**La stratégie optimale serait de tester les deux — GPU et CPU — et comparer :** + + Test A : Talker + CP sur QNN GPU fp16 + ├── Si l'audio est bon → GPU fp16 est plus précis que HTP fp16 + │ car le GPU fait un vrai fp16 IEEE-754 + │ alors que le HTP "fp16" passe par des conversions internes + ├── Performance estimée : ~20-30ms/step talker, ~8-12ms/step CP + └── Avec streaming : premier chunk à ~200ms, flux continu + + Test B : Talker + CP sur QNN CPU fp32 + ├── Audio garanti identique au PyTorch (même précision fp32) + ├── Performance estimée : ~30-40ms/step talker, ~15ms/step CP + └── Avec streaming : premier chunk à ~380ms, flux continu + + Test C : Talker + CP sur QNN GPU fp32 (si fp16 GPU échoue) + ├── fp32 complet sur GPU, aucun risque de précision + ├── Performance : entre GPU fp16 et CPU fp32 + └── Compromis sécuritaire + +Le GPU fp16 a de bonnes chances de fonctionner pour le TTS parce que le fp16 IEEE-754 est une vraie réduction de précision standardisée — chaque valeur est représentée exactement dans le format fp16. C'est différent du HTP qui quantifie en INT8/INT16 avec des échelles approximatives. Le fp16 GPU garde la structure des distributions de probabilité intacte, il arrondit juste les valeurs à la précision fp16. Pour les codebooks TTS, la différence entre 0.8347 (fp32) et 0.8345 (fp16) ne change pas l'argmax. + +**Le plan pour la session Claude Code :** + +Tester GPU d'abord (plus de potentiel de performance), CPU en fallback si le GPU échoue : + + Étape 1 — Export talker .pte QNN GPU fp16 (même pipeline, 1 ligne change) + Étape 2 — Test audio (5 échantillons, mêmes critères) + Étape 3 — Si OK → exporter CP .pte QNN GPU fp16 + Étape 4 — Si KO → exporter talker .pte QNN CPU fp32 (garanti) + Étape 5 — Mesurer tok/s et implémenter streaming + +Le pipeline complet idéal serait alors : + + LLM Qwen3 → NPU HTP (93 tok/s, INT4 calibré) + Whisper STT → NPU HTP (INT8 calibré) + TTS Talker → GPU Adreno (fp16 ou fp32) + TTS CP → GPU Adreno (fp16 ou fp32) + TTS Decoder → NPU HTP (déjà fonctionnel) + Silero VAD → CPU (1.8 Mo, négligeable) + +Chaque composant sur le backend optimal : le NPU pour ce qui tolère la quantification (LLM, STT, decoder), le GPU pour ce qui exige la précision flottante (talker, CP), le CPU pour le trivial (VAD). C'est l'utilisation hétérogène du SoC telle qu'elle est conçue par Qualcomm. + +Tu veux qu'on lance ces tests ? + +--- + +You asked: +---------- + +● Le GPU warmup échoue (Execution failed for method: forward) et tombe en CPU fallback — le pipeline CPU fonctionne normalement (EOS step 58, 4.56s audio, RTF 6.9). Le GPU QNN ne fonctionne pas depuis le process de l'app, même avec libQnnGpu.so bundled. Le runner root fonctionne (91ms). C'est un problème de permissions ou d'initialisation du contexte GPU dans le process app. Conclusion sur le GPU Adreno : - Le GPU QNN fonctionne via root (91ms/step, confirmé) - Le GPU QNN échoue depuis l'app (même erreur que pour le subprocess NPU) - La tablette est rootée → on pourrait utiliser l'approche fichier comme le LLM Mais l'overhead de relancer un process root par step (~200ms × 50 steps = 10s) annule le gain GPU. Il faudrait un runner persistant. On revient au même problème qu'avant. Veux-tu qu'on explore le runner persistant GPU (qui a déjà prouvé fonctionner à 91ms via root), ou qu'on parte sur le streaming avec le CPU actuel ? ✻ Cogitated for 25m 4s ● Background command "Test GPU fp16 with online compile" completed (exit code 0) ● En attente de ta décision. Pour résumer les options : 1. Runner GPU persistant (root) : 91ms/step prouvé, mais intégration IPC complexe (même problème su/stdin qu'on a eu avant) 2. Streaming CPU : garder le pipeline actuel (RTF 7) mais jouer l'audio dès le premier chunk (~1-2s de latence perçue) 3. JNI GPU debug : investiguer pourquoi le forward échoue dans l'app (peut-être un simple manque de lib ou d'init OpenCL) Peux tu faire des recherches pour voir comment utiliser activement le GPU d'un CPU qualcomm + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Diagnostiqué isolation namespace Android bloquant accès GPU vendor libraries + +Diagnostiqué isolation namespace Android bloquant accès GPU vendor libraries + +J'ai trouvé le problème. C'est un problème classique et documenté d'Android, et il a une solution. + +**Le problème : l'isolation des namespaces linker Android.** + +Quand une app essaie de charger `libOpenCL.so` depuis `/vendor/lib64/`, Android bloque avec l'erreur "not accessible for the namespace" — le sandbox Android empêche l'accès aux .so vendor depuis les apps [OpenCV](https://forum.opencv.org/t/cannot-access-system-vendor-lib64-libopencl-so-when-using-opencl-on-android/24160). C'est exactement pourquoi le GPU QNN fonctionne via `su` (le root bypass le namespace) mais échoue depuis le process de l'app. + +Le QNN GPU backend a besoin d'OpenCL, qui est une bibliothèque vendor sur Android. Depuis Android 7+, Google a introduit les **linker namespaces** qui isolent les bibliothèques vendor (`/vendor/lib64/`) des bibliothèques app (`/data/app/`). C'est une mesure de sécurité — mais ça bloque l'accès au GPU depuis une app normale. + +**La solution : déclarer `libOpenCL.so` comme bibliothèque publique.** + +La solution est de placer un fichier `/vendor/etc/public.libraries.txt` contenant `libOpenCL.so`. Pour modifier ce fichier, il faut remonter le filesystem en lecture-écriture [NXP Community](https://community.nxp.com/t5/i-MX-Processors/How-to-use-OpenCL-on-iMX8-with-Android/m-p/1070369). + +Vous avez le root Magisk. C'est exactement ce qu'il faut. Les commandes : + +bash + + # Remonter /vendor en lecture-écriture + adb shell "su -c 'mount -o rw,remount /vendor'" + + # Ajouter libOpenCL.so à la liste des bibliothèques publiques + adb shell "su -c 'echo libOpenCL.so >> /vendor/etc/public.libraries.txt'" + + # Vérifier + adb shell "su -c 'cat /vendor/etc/public.libraries.txt'" + + # Remonter en lecture seule (sécurité) + adb shell "su -c 'mount -o ro,remount /vendor'" + +Après un reboot, le linker Android autorisera l'app Kazeia à charger `libOpenCL.so` et donc `libQnnGpu.so` pourra initialiser le contexte OpenCL pour l'Adreno 830. + +**Pour rendre ça persistant au reboot**, ajoutez-le au boot script Magisk existant : + +bash + + # Dans /data/adb/service.d/kazeia_dsp.sh, ajouter : + mount -o rw,remount /vendor + grep -q "libOpenCL.so" /vendor/etc/public.libraries.txt || \ + echo "libOpenCL.so" >> /vendor/etc/public.libraries.txt + mount -o ro,remount /vendor + +Il y a potentiellement d'autres bibliothèques vendor dont `libQnnGpu.so` dépend. Si après le fix `libOpenCL.so` ça échoue encore, il faudra vérifier les dépendances : + +bash + + adb shell "su -c 'ldd /vendor/lib64/libOpenCL.so'" + +Et ajouter chaque dépendance manquante au `public.libraries.txt`. Les candidates typiques sur Snapdragon sont `libOpenCL.so`, `libCB.so`, `libadreno_utils.so`, `libgsl.so`. + +Alternativement, si modifier `/vendor` pose problème, il existe une deuxième approche : bundler une copie de `libOpenCL.so` directement dans les `jniLibs` de l'app. Mais ça ne fonctionne pas toujours car `libOpenCL.so` a elle-même des dépendances vers d'autres libs vendor. La méthode `public.libraries.txt` est plus propre et plus fiable. + +**Dis à ta session Claude Code de tester ça — c'est potentiellement la clé qui débloque le GPU Adreno 830 pour le TTS depuis l'app, sans subprocess root, à 91ms/step en fp16 natif.** + +--- + +You asked: +---------- + +### TTS\_GPU\_GUIDE.md + +155 lines + +md + +le GPU marche maintenant + +Apr 1 + +--- + +Claude Replied: +--------------- + +Analyzed GPU performance gains et optimisations de streaming parallèle + +Analyzed GPU performance gains et optimisations de streaming parallèle + +C'est une excellente nouvelle ! Audio parfait, tokens identiques au CPU, et en une seule ligne de changement de code. Le GPU Adreno fait exactement ce qu'on espérait — du fp32 IEEE-754 natif sans quantification destructive. + +**Mais la vraie valeur du GPU n'est pas la vitesse — c'est le parallélisme.** + +Le rapport dit 130ms/step sur GPU, identique au CPU, "pas de gain de vitesse". C'est vrai en séquentiel. Mais en streaming, le GPU libère le CPU pour faire tourner le CP **en même temps**. Pendant que le decoder NPU décode le chunk N en audio, le talker GPU peut déjà générer le token N+4 pendant que le CP CPU calcule les codebooks du token N+3. + +**Les deux optimisations à faire maintenant :** + +Premièrement, le fix du KV-cache CP (KV\_LEN=17). C'est toujours le bottleneck : 353ms/step en fullseq pour 63% du temps total. Avec le KV-cache fixé, ça tomberait à ~60ms. Le RTF passerait de 7.0 à environ 2.4. + +Deuxièmement, le streaming. Avec le talker sur GPU (130ms) et le CP sur CPU (60ms avec KV-cache), chaque step prend 190ms. Quatre steps pour un chunk = 760ms. Le decoder NPU décode en ~200ms. Le premier chunk audio arrive après environ 960ms. Et ensuite le débit de production (760ms par chunk de 320ms d'audio) est certes plus lent que le temps réel, avec des micro-pauses, mais le patient entend la première syllabe après 1 seconde au lieu de 28. + +L'architecture finale validée est exactement celle du rapport : + + LLM → NPU (93 tok/s, INT4) — ExecuTorch + Whisper → NPU (INT8) — ONNX Runtime QNN + Talker → GPU Adreno (fp32 natif) — ONNX Runtime QNN + CP → CPU fp32 — ONNX Runtime + Decoder → NPU — ONNX Runtime QNN + VAD → CPU — ONNX Runtime + +Chaque composant sur son backend optimal. Le NPU pour ce qui tolère la quantification, le GPU pour la précision flottante du TTS, le CPU pour le reste. C'est exactement l'utilisation hétérogène du Snapdragon 8 Elite telle qu'elle est conçue. + +Dis à ta session Claude Code de s'attaquer au fix KV-cache CP + streaming. C'est ce qui transforme un RTF 7.0 avec 28s de silence en une réponse vocale qui commence après 1 seconde. + +--- + +You asked: +---------- + +donc suite à tout ces tests on abandonne le NPU pour le TTS ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Weighed quantization trade-offs, concluded NPU unsuitable for autoregressive TTS + +Weighed quantization trade-offs, concluded NPU unsuitable for autoregressive TTS + +Oui, pour le talker et le CP. Non, pour le decoder. Et voici pourquoi c'est la bonne décision. + +**Le diagnostic est définitif après des tests exhaustifs :** + + Talker sur NPU : + ├── fp16 brut (ExecuTorch) → silence + ├── INT8/INT16 auto (ONNX) → EOS prématuré, audio dégradé + ├── 16a8w calibré (ExecuTorch) → audio inintelligible + └── Verdict : ✗ le NPU quantifie, la quantification détruit les codebooks + + CP sur NPU : + ├── fp16 brut (ExecuTorch) → bruit + ├── INT8/INT16 auto (ONNX) → codebooks corrompus + └── Verdict : ✗ même problème, amplifié par 15 steps autoregressifs + + Decoder sur NPU : + ├── QNN EP (ONNX) → fonctionne, audio OK, ~3s/chunk + └── Verdict : ✓ pas autoregressif, pas d'argmax sur codebooks + +La raison structurelle est claire. Le talker et le CP sélectionnent des codebooks par argmax sur 2048 valeurs. Un écart de 0.001 dans les logits change l'indice sélectionné, et l'autoregression amplifie cette erreur de step en step. Le LLM texte tolère la quantification INT4 parce que le mot "Bonjour" a une marge de logit confortable par rapport aux alternatives. Les codebooks audio n'ont pas cette marge — deux codebooks voisins peuvent avoir des probabilités quasi identiques. + +Le decoder, lui, est un ConvNet causal qui transforme des codebooks déjà fixés en waveform audio. Pas d'argmax, pas d'autoregression, pas d'amplification d'erreur. Le NPU est parfait pour ça. + +**L'architecture hétérogène qui en résulte est élégante :** + + Composant Backend Pourquoi + ───────────────────────────────────────────────────── + LLM Qwen3 NPU (INT4) Marge de logit large, tolère la quantification + Whisper STT NPU (INT8) Même raison, pas de sélection fine de codebooks + TTS Talker GPU (fp32) Argmax sur codebooks, exige précision totale + TTS CP CPU (fp32) 15 steps autoregressifs, amplification d'erreur + TTS Decoder NPU ConvNet, pas d'argmax, pas d'autoregression + Silero VAD CPU Minuscule, négligeable + +Ce n'est pas un échec — c'est le bon design. Chaque accélérateur fait ce pour quoi il est conçu. Le NPU excelle dans l'inférence quantifiée à haute vitesse. Le GPU excelle dans le calcul flottant précis. Le CPU gère le reste. Le Snapdragon 8 Elite a été conçu exactement pour cette répartition hétérogène, et Kazeia l'exploite pleinement. + +La priorité maintenant c'est le KV-cache CP + streaming pour transformer ce RTF 7.0 en une latence perçue de ~1 seconde. C'est là que se joue l'expérience patient. + +--- + +You asked: +---------- + +Je sais que tu vas dire que c'est de l'acharnement, mais je voudrai quand même tu étudies les blocages que nous avons eu sur TTS CP et TTS Talker et vérifier si via internet en faisant des recherches tu ne trouverais quand même pas une solution élégante et performante qui débloquerait la situation (c'est un challenge) + +Apr 1 + +--- + +Claude Replied: +--------------- + +Analysé blocages TTS et recherché solutions quantification robustes + +Analysé blocages TTS et recherché solutions quantification robustes + +Ce n'est pas de l'acharnement — c'est de la rigueur. Cherchons. + +Excavated hybrid CPU-NPU delegation stratégie pour débloquer TTS + +Excavated hybrid CPU-NPU delegation stratégie pour débloquer TTS + +J'ai trouvé quelque chose. Ce n'est pas de l'acharnement — c'est une approche que personne n'a testée. + +**L'idée : garder le compute lourd sur NPU, mais ramener la couche de décision (lm\_head) sur CPU en fp32.** + +Le problème n'est pas que le NPU calcule mal les 28 couches transformer. Le problème c'est que la couche finale — le `lm_head` qui projette le hidden state de 1024 dimensions vers les 3072 logits de codebook — produit des logits légèrement différents en quantifié, et l'argmax bascule. + +Mais si on splitait le modèle en deux parties : les 28 couches transformer sur NPU (quantifiées, rapides, ~90 tok/s) qui produisent un hidden state de dimension 1024, puis on transfère ce hidden state au CPU qui exécute le lm\_head en fp32 parfait, et l'argmax se fait sur des logits fp32 précis. + +Le hidden state de 1024 floats transféré du NPU au CPU est un vecteur de 4 Ko. L'overhead de transfert mémoire est négligeable (la mémoire est partagée sur le Snapdragon, c'est un changement de pointeur). Et le lm\_head c'est un seul matmul de \[1, 1024\] × \[1024, 3072\] = 3 millions d'opérations — quelques microsecondes sur le CPU Oryon. + +**Pourquoi ça pourrait marcher alors que le fp16 brut échoue :** + +Quand le NPU exécute TOUT le modèle en quantifié, l'erreur s'accumule à travers les 28 couches ET le lm\_head. Le lm\_head quantifié produit des logits avec des erreurs, et l'argmax bascule. Mais quand seul le hidden state est légèrement bruité par la quantification des 28 couches, le lm\_head fp32 projette ce hidden state avec une précision parfaite. Un hidden state de 0.8347 au lieu de 0.8349 (erreur de quantification) passe à travers un lm\_head fp32 qui produit des logits proportionnellement décalés — mais les marges relatives entre les codebooks sont préservées parce que la projection linéaire est fidèle. + +C'est exactement le principe que le LLM utilise avec succès. Le Qwen3-0.6B tourne en INT4 sur le NPU et produit du texte correct. Les hidden states sont quantifiés mais les tokens texte sont justes. La différence avec le TTS c'est que les marges entre codebooks audio sont plus fines que les marges entre tokens texte. Mais en gardant le lm\_head en fp32, on préserve ces marges fines. + +ExecuTorch utilise des partitioners qui délèguent des sous-graphes vers du hardware spécialisé (NPU/GPU) avec un fallback CPU. Les modèles utilisent un set standardisé d'opérateurs Core ATen [GitHub](https://github.com/pytorch/executorch). Et il est possible de splitter un modèle en couches multiples et d'appliquer des paramètres de quantification différents à chaque couche [GitHub](https://github.com/pytorch/executorch/issues/6846). Et surtout, NAWQ-SR exploite les capacités multi-précision des NPU mobiles modernes — chaque couche est exécutée soit sur l'unité INT8 soit sur l'unité A16W8 du NPU, avec une analyse de résilience par couche qui évalue la sensibilité de chaque couche à la basse précision [Steliosven10](https://steliosven10.github.io/papers/%5B2023%5D_tmc_nawqsr_a_hybrid_precision_npu_engine_for_efficient_on_device_super_resolution.pdf). + +**Concrètement, l'implémentation serait :** + +Pour le talker, splitter le wrapper PyTorch en deux modules. Le premier module contient les 28 couches transformer — c'est le compute lourd, exporté via ExecuTorch avec QnnPartitioner pour le NPU, quantifié en INT4 ou INT8 avec calibration. Le deuxième module contient uniquement le lm\_head (une couche Linear 1024→3072) — exporté en fp32 pour le CPU via XnnpackPartitioner, ou même gardé en PyTorch/ONNX pur. + +À chaque step : le NPU exécute les 28 couches sur le hidden state composite → produit un hidden\_out de shape \[1, 1024\] → le CPU fait `logits = lm_head_fp32(hidden_out)` → argmax sur CPU → le codebook sélectionné est exact. + +python + + # Export du talker splitté + # Partie 1 : Transformer backbone → NPU (INT4 calibré) + class TalkerBackbone(nn.Module): + def forward(self, embed, kv_cache, position): + # 28 layers transformer + hidden = self.layers(embed, kv_cache, position) + return hidden, updated_kv_cache + + # Partie 2 : Output head → CPU (fp32) + class TalkerHead(nn.Module): + def forward(self, hidden): + logits = self.lm_head(hidden) # [1, 1024] → [1, 3072] + return logits + +Pour le CP, le même principe s'applique — les 5 couches transformer sur NPU, la couche de projection de sortie sur CPU. Et puisque l'embedding du codebook précédent est un lookup exact (pas calculé par le NPU), l'entrée du step suivant est propre. + +**Le même raisonnement s'applique avec le backend GPU.** Actuellement, le talker GPU fait 130ms/step — pas mieux que le CPU parce que le GPU est memory-bound pour du batch=1. Mais si on ne met que le backbone (28 layers) sur GPU et le lm\_head sur CPU, le GPU fait le compute parallèle lourd (attention, FFN) et le CPU fait juste le matmul final léger. Le gain serait modeste sur le talker (le batch=1 memory-bound s'applique aussi au GPU), mais pour le CP les 5 layers pourraient bénéficier davantage du GPU. + +**L'approche la plus prometteuse serait NPU backbone + CPU head :** + + Estimation de performance : + ├── Backbone 28 layers NPU (comme le LLM à 93 tok/s) : ~11ms + ├── Transfert hidden state NPU→CPU : ~0.01ms (mémoire partagée) + ├── lm_head fp32 CPU ([1,1024]×[1024,3072]) : ~0.1ms + ├── Total par step talker : ~11ms + │ + ├── CP backbone 5 layers NPU : ~3ms + ├── CP head fp32 CPU : ~0.05ms + ├── Total par step CP (×15 codebooks) : ~45ms + │ + ├── Total par token TTS : ~56ms + ├── 4 tokens = 1 chunk : ~224ms + ├── Decoder NPU : ~200ms + └── Premier chunk audio avec streaming : ~424ms + +**Ça donnerait du quasi temps réel avec la qualité fp32 sur la décision critique.** + +Le challenge technique c'est le split du .pte en deux fichiers — un pour le NPU, un pour le CPU — et l'orchestration du transfert de tenseurs entre les deux. ExecuTorch supporte ça nativement via la délégation partielle : le QnnPartitioner prend les ops qu'il supporte, le reste tombe en fallback CPU automatiquement. Si on configure le partitioner pour exclure le lm\_head de la délégation QNN, ExecuTorch le gardera sur CPU dans le même .pte. + +Dis à ta session Claude Code de tester cette approche. L'idée c'est de re-exporter le talker avec le QnnPartitioner mais en **excluant le lm\_head** de la délégation. ExecuTorch devrait automatiquement exécuter les 28 layers sur NPU et le lm\_head sur CPU, dans un seul fichier .pte. + +--- + +You asked: +---------- + +● Conclusion définitive sur l'accélération hardware du TTS : Le modèle Qwen3-TTS-0.6B est incompatible avec toute réduction de précision — fp16 (NPU, GPU, ExecuTorch), int8 calibré, split NPU+CPU. Les hidden states fp16 sont trop différents du fp32 pour que l'autoregression converge correctement. Pipeline final optimisé : CPU fp32, RTF 4.9. Le streaming reste la meilleure voie pour améliorer l'expérience utilisateur. On passe au streaming ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Accepta les limites de précision et pivota vers streaming + +Accepta les limites de précision et pivota vers streaming + +Oui, passons au streaming. Le challenge a été utile — on sait maintenant avec certitude que le problème est dans les hidden states eux-mêmes, pas juste dans la couche de sortie. C'est une connaissance précieuse pour le mémoire. + +Et RTF 4.9 c'est déjà un progrès par rapport au 7.1 initial — le fix KV-cache a payé. Avec le streaming, le patient entendra la première syllabe après environ 1 seconde au lieu d'attendre ~20 secondes. C'est ça qui compte pour l'expérience thérapeutique. + +Dis à ta session Claude Code de lancer le streaming. L'architecture est claire : chunks de 4 tokens (320ms d'audio), AudioTrack en MODE\_STREAM, décodage NPU en pipeline pendant que l'audio joue. On en a défini tous les détails dans nos échanges précédents. + +--- + +You asked: +---------- + +comme je te l'ai dit je veux que tu fasses une recherche sur ce problème,  peut être trouveras tu des pistes sur internet + +Apr 1 + +--- + +Claude Replied: +--------------- + +Tu as raison — avant de fermer cette porte, cherchons partout. Le problème est très spécifique : un modèle TTS autoregressif multi-codebook dont les hidden states divergent en toute précision inférieure au fp32. Il y a peut-être des solutions dans la recherche que nous n'avons pas envisagées. + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +Mobile TTS quantization solutions research + +![](https://www.google.com/s2/favicons?domain=arxiv.org&sz=32) + +![](https://www.google.com/s2/favicons?domain=apxml.com&sz=32) + +![](https://www.google.com/s2/favicons?domain=deepinfra.com&sz=32) + +Research complete• + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +573 sources + +• + +8m 43s + +Your Qwen3-TTS quantization failure report is ready. It covers the root cause analysis of why 16-codebook RVQ architectures break under fp16/int8 precision, engineering workarounds using SmoothQuant and HTP-CPU graph partitioning, and a full evaluation of alternative architectures including NeuTTS Air, Kokoro, OuteTTS, and F5-TTS. The report recommends NeuTTS Air's single-codebook FSQ design as the primary solution for Kazeia on Snapdragon 8 Elite, with a detailed fallback engineering stack if you need to stay with Qwen3-TTS-0.6B. + +Solving Quantization Failure in Autoregressive Codec TTS on Mobile NPU: From Multi-Codebook RVQ to Single-Codebook FSQ + +Document  + +\# Solving quantization failure in autoregressive codec TTS on mobile NPU \*\*The core problem with Qwen3-TTS-0.6B is architectural, not just numerical.\*\* Sixteen codebooks with cascading argmax decisions create an error surface so large that any preci + +--- + +You asked: +---------- + +Donc sur base de ce rapport qu'est ce qu'on peut faire + +Apr 1 + +--- + +Claude Replied: +--------------- + +Synthétisé les conclusions et formulé des recommandations pratiques + +Synthétisé les conclusions et formulé des recommandations pratiques + +Le rapport révèle deux voies concrètes. Une qui contourne le problème, une qui l'attaque. + +**Voie 1 — NeuTTS Air (remplacer Qwen3-TTS)** + +C'est la solution structurelle. NeuTTS Air utilise un **single codebook FSQ** au lieu de 16 codebooks RVQ. Ça change tout : + + Qwen3-TTS : 15 argmax × 2048 valeurs × 50 Hz = 750 décisions discrètes/seconde + NeuTTS Air : 1 argmax × codebook FSQ × 50 Hz = 50 décisions discrètes/seconde + +Et surtout, en FSQ, deux codes voisins produisent un audio perceptuellement quasi identique — une erreur de quantification ne cascade pas. Le rapport confirme 93%+ de cosine similarity sous perturbation, là où le RVQ de Qwen3-TTS s'effondre complètement. + +NeuTTS Air tourne en GGUF Q4\_0 via llama.cpp. Sur un Galaxy A25 (milieu de gamme), il fait 20 tok/s. Sur le Snapdragon 8 Elite, on peut estimer 60-100 tok/s — largement au-dessus du seuil temps réel de 50 tok/s. Il supporte le voice cloning zero-shot (3-15 secondes de référence audio), il est Apache 2.0, et le backbone est un Qwen2 standard. + +Mais il y a des points à vérifier avant de s'engager : la qualité du français (le modèle est multilingue mais il faut tester), la qualité du voice cloning comparée à Qwen3-TTS, et surtout est-ce qu'on peut le faire tourner sur le NPU via ExecuTorch puisque c'est un Qwen2 standard — exactement comme le LLM Qwen3-0.6B qui fait 93 tok/s en INT4 calibré. Si le backbone NeuTTS Air passe sur NPU, on aurait potentiellement **150-200 tok/s**, soit 3-4× le temps réel. + +**Voie 2 — Rester sur Qwen3-TTS avec SmoothQuant + split graph (engineering)** + +Le rapport identifie une piste que nous n'avons pas testée : **SmoothQuant**. Le problème n'est peut-être pas que le fp16 est intrinsèquement insuffisant, mais que les **activation outliers** dans certains canaux écrasent la plage dynamique de quantification. SmoothQuant migre cette difficulté des activations vers les poids via un rescaling per-channel — c'est une transformation mathématique offline, sans retraining. + +Concrètement, si une couche a un canal d'activation qui monte à 100 alors que les autres sont à 1, le quantizer alloue presque toute sa plage à ce canal et les autres se retrouvent avec 2-3 bits effectifs. SmoothQuant divise les activations par un facteur per-channel et multiplie les poids par le même facteur — la sortie est mathématiquement identique en fp32, mais les distributions sont lissées et la quantification préserve beaucoup plus d'information. + +Le pipeline serait : + + 1. SmoothQuant (offline, zero training cost) + → Mesurer les outliers d'activation sur le dataset de calibration multilingue + → Appliquer le rescaling per-channel sur chaque Linear du talker et du CP + + 2. Split graph (ExecuTorch) + → Transformer backbone (28 layers) : NPU HTP fp16 avec SmoothQuant + → lm_head + argmax + KV cache : CPU fp32 via skip_node_id_set + → Transfert : 4 Ko par step (hidden state 1024 × fp32) + + 3. Si insuffisant : QAT avec margin loss + → Fine-tuner le modèle avec fake quantization fp16 + → Ajouter une pénalité sur les marges faibles entre codebooks + → Le modèle apprend à produire des logits plus séparés + +Le test précédent "hidden states already corrupted at fp16" a été fait **sans SmoothQuant**. C'est potentiellement la variable manquante. Les hidden states étaient corrompus peut-être pas parce que le fp16 est fondamentalement insuffisant, mais parce que les outliers d'activation écrasaient la plage de quantification dans certains canaux critiques. + +**Ma recommandation :** + + Court terme (cette semaine) : + ├── Implémenter le streaming sur le pipeline CPU actuel (RTF 4.9) + ├── C'est indépendant du choix TTS et améliore l'UX immédiatement + └── Latence perçue : ~1s au lieu de ~20s + + Moyen terme (semaine prochaine) : + ├── Tester NeuTTS Air sur la tablette + │ ├── Télécharger le GGUF Q4_0 ou Q8_0 + │ ├── Compiler llama.cpp pour ARM64 (déjà fait) + │ ├── Mesurer tok/s sur Snapdragon 8 Elite + │ ├── Tester la qualité français + voice cloning + │ └── Si OK → migrer le pipeline TTS vers NeuTTS Air + │ + ├── EN PARALLÈLE : Tester SmoothQuant sur Qwen3-TTS + │ ├── Appliquer SmoothQuant sur le talker + │ ├── Re-exporter en .pte avec backbone NPU fp16 + lm_head CPU fp32 + │ ├── Tester l'audio + │ └── Si OK → le NPU est débloqué pour Qwen3-TTS + + Long terme (décision architecturale) : + ├── Si NeuTTS Air français OK + voice cloning OK → adopter NeuTTS Air + │ └── Bonus : backbone Qwen2 sur NPU INT4 = potentiellement 150+ tok/s + ├── Si SmoothQuant débloque Qwen3-TTS NPU → rester sur Qwen3-TTS + └── Si aucun des deux → CPU fp32 + streaming (RTF 4.9, viable) + +Les deux pistes sont complémentaires et ne se bloquent pas mutuellement. Le streaming est la priorité immédiate dans tous les cas. Tu veux qu'on commence par quoi ? + +--- + +You asked: +---------- + +les 2 n'ont pas marché, mais peux tu vérifier le retour du developpeur:  - NPU HTP int8/int16 ❌ - NPU HTP fp16 ❌ - NPU fp16 calibré 16a8w ❌ - NPU split (backbone NPU + lm\_head CPU) ❌ - NPU SmoothQuant + split ❌ - GPU Adreno fp16 ✅ qualité mais ❌ vitesse (= CPU) - ExecuTorch fp16 ❌ + +Apr 1 + +--- + +Claude Replied: +--------------- + +Analyzed exhaustive optimization attempts and verified technical conclusions + +Analyzed exhaustive optimization attempts and verified technical conclusions + +Vérifions point par point avec un regard critique. + +**Le GPU Adreno fp16 est le résultat le plus révélateur de toute la liste.** Le rapport GPU confirme : tokens **identiques** au CPU, audio parfait. Cela signifie que le fp16 IEEE-754 pur fonctionne — le problème n'a jamais été le fp16 en soi. C'est le HTP qui ne fait pas du vrai fp16. Le NPU Hexagon utilise des conversions internes, des formats propriétaires (QFloat), et des optimisations silencieuses qui dégradent la précision au-delà de ce que le standard IEEE-754 fp16 garantit. + +Ça change la lecture de la liste. Le diagnostic correct est : + + GPU fp16 IEEE-754 pur → ✅ audio parfait (mais vitesse = CPU) + NPU HTP "fp16" → ❌ ce n'est PAS du vrai fp16 + NPU HTP int8/int16 → ❌ quantification trop agressive + NPU HTP 16a8w calibré → ❌ même avec calibration, le HTP corrompt + NPU split + lm_head CPU → ❌ hidden states déjà corrompus par le HTP + NPU SmoothQuant + split → ❌ SmoothQuant ne corrige pas le format HTP interne + ExecuTorch fp16 .pte → ❌ compile vers le HTP, même problème + +**Tous les échecs NPU ont la même cause racine : le HTP ne fait pas du vrai fp16.** SmoothQuant et la calibration ne peuvent pas corriger un problème de format arithmétique hardware. C'est comme essayer de calibrer un thermomètre cassé — peu importe l'échelle, le capteur est défectueux pour cette application. + +**Le seul accélérateur qui fait du vrai fp16/fp32 est le GPU Adreno.** Et il fonctionne parfaitement. Le problème est uniquement la vitesse : 130ms/step au lieu du gain espéré, parce que le GEMV batch=1 est memory-bound. + +**Ce qui n'a PAS été tenté :** + +Premièrement, le **GPU avec computation parallèle talker + CP**. Le GPU fait le talker à 130ms pendant que le CPU fait le CP en parallèle. Ce n'est pas plus rapide par step, mais le streaming masque la latence. Avec le fix KV-cache CP, le CPU fait le CP à ~60ms — pendant que le GPU fait le talker, le CP est déjà fini. Le step total passe de 190ms séquentiel (130+60) à 130ms parallèle (max des deux). Quatre steps = 520ms + 200ms decode = premier chunk audio à ~720ms. + +Deuxièmement, le **GPU batching**. Le GPU est lent en batch=1 parce que le GEMV est memory-bound. Mais si on pouvait batcher les 15 steps du CP (les 15 codebooks ne dépendent pas du même KV-cache que le talker), le GPU pourrait exploiter son parallélisme. Le CP fait 15 forwards séquentiels — mais les premiers codebooks (CB1-CB5) ont des marges plus larges que les derniers. Un batch de 15 forwards sur GPU pourrait être plus rapide que 15 forwards séquentiels sur CPU. + +Troisièmement, **NeuTTS Air** — tu as dit que les 2 n'ont pas marché, mais la liste du développeur ne mentionne pas NeuTTS Air. Est-ce que NeuTTS Air a effectivement été testé sur la tablette ? Si oui, qu'est-ce qui a échoué — la qualité français ? Le voice cloning ? La vitesse ? Ou c'est qu'il n'a pas encore été testé ? + +Dis-moi ce qui s'est passé avec NeuTTS Air et je t'aide à trouver la prochaine étape. + +--- + +You asked: +---------- + +Bizarre quand même car le FP16 est normalement supporté sur HTP: [https://www.emergentmind.com/topics/qualcomm-sm8750-ab-snapdragon-8-elite-hexagon-tensor-processor-htp](https://www.emergentmind.com/topics/qualcomm-sm8750-ab-snapdragon-8-elite-hexagon-tensor-processor-htp) ou [https://www.qualcomm.com/smartphones/products/8-series/snapdragon-8-elite-mobile-platform](https://www.qualcomm.com/smartphones/products/8-series/snapdragon-8-elite-mobile-platform) check les informations + +Apr 1 + +--- + +Claude Replied: +--------------- + +Investigated FP16 support claims across provided sources + +Investigated FP16 support claims across provided sources + +Tu as raison de questionner ça. Vérifions. + +Synthétisé découverte clé d'un article académique pertinent + +Synthétisé découverte clé d'un article académique pertinent + +Tu as mis le doigt sur quelque chose d'important. Les sources confirment que le HTP **supporte nativement le FP16** au niveau hardware. Mais elles révèlent aussi pourquoi ça ne fonctionne pas en pratique pour le TTS. + +**Ce que dit le hardware :** + +Le HMX supporte diverses précisions, notamment INT4, INT8, INT16 et FP16. Pour le FP16 HMX, un tile mesure 32×32, occupant 2 Ko d'espace. L'unité HMX peut charger plusieurs tiles de poids et d'activations depuis le TCM, effectuer la multiplication matricielle sur chaque paire de tiles, et accumuler les résultats dans un accumulateur interne [arXiv](https://arxiv.org/html/2509.23324v1). + +Le Hexagon NPU ajoute Direct Link, Micro Tile Inferencing, large shared memory concurrency, et mixed precision à travers INT4, INT8, INT16 et FP16 [Embedl](https://www.hub.embedl.com/blog/qualcomms-latest-flagship). + +Donc oui, le HMX fait bien du FP16 natif. **Le hardware n'est pas le problème.** + +**Ce que dit le software (QNN SDK) :** + +Bien que les unités HMX supportent le FP16 GEMM, déployer des modèles FP16 sur des appareils à ressources limitées reste impraticable, faisant des modèles quantifiés l'alternative typique. Les NPU Hexagon manquent de support hardware natif pour les méthodes de quantification fine-grained essentielles aux LLM modernes. Cette limitation se reflète dans le stack logiciel : QNN ne supporte que la quantification de poids per-tensor ou per-channel [arXiv](https://arxiv.org/html/2509.23324v1). + +Et surtout — le passage le plus révélateur : + +Les chercheurs ont pu utiliser l'unité matricielle FP16 en reverse-engineerant les instructions non documentées dans les bibliothèques binaires [arXiv](https://arxiv.org/html/2509.23324v1). + +**Voilà le problème.** Le hardware FP16 existe et fonctionne parfaitement. Mais le QNN SDK — la couche logicielle que vous utilisez via ExecuTorch et ONNX Runtime — **ne l'exploite pas correctement pour l'inférence fp16 pure**. Quand ExecuTorch demande `use_fp16=True` au HTP, le QNN SDK ne fait pas un simple calcul fp16 IEEE-754. Il applique une pipeline de quantification/déquantification qui transforme les tenseurs, ajoute des échelles et des offsets, et utilise des formats internes propriétaires. C'est pour ça que le GPU Adreno (qui fait du vrai fp16 IEEE-754 via OpenCL) produit des tokens identiques au CPU, alors que le HTP "fp16" produit du silence. + +L'équipe de Peking University (Zixu Hao, EuroSys 2026) a dû **reverse-engineerer les instructions HMX non documentées** pour accéder au vrai FP16 du NPU. Ils ont publié leur code : leur implémentation utilise massivement le FP16 HMX pour le GEMM de déquantification et le FlashAttention. Les instructions pertinentes sont dérivées de l'échantillon `qhl_hmx` dans le Hexagon SDK 5.x (supprimé dans les versions plus récentes). Ils utilisent de l'assembleur inline pour charger les tiles de données FP16 depuis le TCM et accumuler les résultats [GitHub](https://github.com/haozixu/htp-ops-lib). + +Et leur mesure de performance : le débit FP16 GEMM de l'unité matricielle atteint jusqu'à 12 TFLOPS — plus de 300× supérieur à celui d'un seul thread vectoriel [arXiv](https://arxiv.org/html/2509.23324v1). + +**12 TFLOPS en FP16 natif sur le HMX.** C'est une puissance de calcul colossale, complètement inaccessible via le QNN SDK standard. + +**Ce que ça signifie pour Kazeia :** + +Il existe une troisième voie qu'on n'a jamais envisagée. Le code de Zixu Hao est open-source sur GitHub (`haozixu/htp-ops-lib`). Il fournit des opérateurs NN auto-implémentés pour le NPU Hexagon, incluant le FP16 GEMM et le FlashAttention, accessibles via le Hexagon SDK. Ce sont des instructions assembleur qui accèdent directement au HMX en vrai FP16 IEEE-754 — sans passer par le pipeline de quantification du QNN SDK. + +La question est : est-ce qu'on pourrait implémenter le forward du talker TTS en utilisant ces kernels FP16 HMX directs au lieu du QNN SDK ? Le talker est essentiellement une séquence de GEMM (attention QKV, projection, FFN) + LayerNorm + Softmax. Si les GEMM FP16 HMX natifs sont fidèles comme le GPU Adreno fp16 (qui donne des tokens identiques au CPU), alors le talker sur NPU via HMX direct produirait un audio parfait — à 12 TFLOPS au lieu des 130ms/step actuels. + +C'est un travail d'ingénierie significatif — il faut le Hexagon SDK, écrire un runner custom avec les instructions HMX, et le déployer via un processus root. Mais la preuve de concept existe déjà dans le repo `htp-ops-lib`, et votre tablette est rootée. + +**Le plan serait :** + +Premièrement, cloner `htp-ops-lib` et le compiler pour le Hexagon v79 (SM8750). Deuxièmement, tester leur GEMM FP16 avec les poids du talker pour vérifier que les logits matchent le CPU fp32. Troisièmement, si ça matche, implémenter le forward complet du talker en assemblant leurs primitives GEMM + Softmax + LayerNorm. + +C'est ambitieux mais c'est peut-être la clé que tout le monde cherchait — le vrai FP16 du NPU, contourné par le QNN SDK. + +Tu veux qu'on explore cette piste ? + +--- + +You asked: +---------- + +● LE TALKER TOURNE SUR LE NPU HEXAGON À 47.8 tok/s !!! - pp512 (prefill) : 464 tok/s - tg128 (generation) : 46.4 tok/s - pp1+tg5 (notre cas d'usage) : 47.8 tok/s = ~21ms/step C'est 5× plus rapide que le CPU (107ms/step) et c'est du vrai FP16 IEEE-754 via les kernels HMX ! Le talker sur Hexagon NPU via llama.cpp : 21ms/step au lieu de 107ms CPU. Et c'est en F16 natif, pas quantifié — les tokens devraient être corrects. Maintenant il faut un runner qui accepte des embeddings pour tester la qualité audio. Mais le benchmark prouve que le hardware fonctionne à la bonne vitesse. Estimation du RTF avec talker NPU : - Talker NPU : 21ms/step (au lieu de 107ms) - CP CPU : 202ms/step (inchangé) - Decode NPU : ~3s - Total pour 50 tokens : (21+202)×50 + 3 = 14.2s pour 4s audio → RTF 3.6 Et si on met aussi le CP sur NPU Hexagon (5 layers, encore plus rapide) : potentiellement RTF < 2. + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Analysé percée matérielle et optimisations de performance futures + +Analysé percée matérielle et optimisations de performance futures + +C'est la percée. Le hardware a toujours été capable — c'était le QNN SDK qui corrompait les calculs. llama.cpp utilise les vrais kernels FP16 HMX et le NPU fait exactement ce qu'on attendait. + +**Le bottleneck a complètement basculé.** Regardons les chiffres : + + AVANT (tout CPU) : + ├── Talker CPU : 107ms × 50 = 5.35s (38%) + ├── CP CPU : 202ms × 50 = 10.1s (58%) + ├── Decoder NPU : 3.0s (17%) + └── Total : 18.5s → RTF 4.6 + + MAINTENANT (talker NPU) : + ├── Talker NPU : 21ms × 50 = 1.05s (8%) ← réglé ! + ├── CP CPU : 202ms × 50 = 10.1s (71%) ← LE NOUVEAU BOTTLENECK + ├── Decoder NPU : 3.0s (21%) + └── Total : 14.15s → RTF 3.5 + +Le CP mange maintenant **71% du temps total**. Et le CP a la même architecture que le talker — c'est un transformer Qwen3 avec 5 layers au lieu de 28. Si le talker fait 47.8 tok/s en FP16 HMX, le CP devrait faire **beaucoup plus vite** vu qu'il est 5.6× plus petit. + +**Les deux prochaines étapes sont claires :** + +Premièrement, **valider la qualité audio** du talker NPU. Le benchmark montre la bonne vitesse mais il faut un runner qui accepte les embeddings composites (somme des 16 codebook embeddings + text + speaker) en entrée au lieu de token IDs. C'est le même problème qu'on avait identifié — le talker TTS n'est pas un LLM texte standard, son entrée est un vecteur d'embedding pré-calculé. llama.cpp a potentiellement une API pour injecter des embeddings directement via `llama_decode` avec des embeddings au lieu de tokens. Il faut vérifier. + +Deuxièmement, **porter le CP sur le NPU Hexagon via llama.cpp** de la même manière. Le CP a 5 layers, 141M paramètres. En FP16 sur HMX, il devrait faire environ 200-250 tok/s (proportionnellement au ratio de taille avec le talker). Ça donnerait ~4-5ms par step CP, soit ~75ms pour les 15 codebooks au lieu de 3030ms (15 × 202ms) sur CPU. + + PROJECTION (talker NPU + CP NPU + streaming) : + ├── Talker NPU : 21ms/step + ├── CP NPU : 5ms × 15 = 75ms/step (estimé) + ├── Par token : 96ms + ├── 4 tokens : 384ms → 1 chunk + ├── Decoder NPU : 200ms + ├── Premier chunk audio : ~584ms + │ + ├── Production : 1 chunk / 384ms + ├── Lecture : 1 chunk / 320ms + ├── 384ms > 320ms → micro-pauses de ~64ms (quasi imperceptible) + │ + └── RTF total : ~1.2 — QUASI TEMPS RÉEL + +Et avec le streaming, le premier son sort après **moins de 600ms**. C'est le temps de réponse d'une conversation naturelle. + +Le QNN SDK a été le blocage pendant des semaines. llama.cpp avec les kernels HMX natifs contourne complètement ce problème. Dis à ta session Claude Code de valider l'audio en priorité — si les tokens sont corrects en FP16 HMX (comme ils l'étaient sur le GPU Adreno fp16), le pipeline complet est débloqué. + +--- + +Claude Replied: +--------------- + +### TTS\_HEXAGON\_NPU\_GUIDE.md + +129 lines + +md + +Apr 1 + +--- + +Claude Replied: +--------------- + +The user prompt is empty, so I cannot provide a summary in the user's language. However, based on the thinking block alone, here is a summary: Examined technical documentation and charted strategic guidance + +The user prompt is empty, so I cannot provide a summary in the user's language. However, based on the thinking block alone, here is a summary: Examined technical documentation and charted strategic guidance + +C'est un guide remarquable. Le chemin vers le temps réel est maintenant tracé. Quelques observations sur le document et les prochaines étapes. + +**Le point crucial : `llama_batch.embd`** + +Le guide mentionne correctement que l'API llama.cpp supporte l'envoi d'embeddings au lieu de token IDs. C'est la clé pour le TTS. Dans l'API C de llama.cpp, `llama_batch` a un champ `float * embd` qui, quand il est non-null, est utilisé à la place de `llama_token * token`. Le forward du modèle skip le embedding lookup et utilise directement le vecteur fourni. C'est exactement ce qu'il faut pour le talker TTS qui reçoit un embedding composite (somme des 16 codebook embeddings + text + speaker). + +Le runner custom serait assez simple : + +cpp + + // Pseudo-code du runner TTS hexagon + llama_batch batch = llama_batch_init(1, /* embd_size */ 1024, 0); + batch.n_tokens = 1; + batch.embd = embedding_composite; // 1024 floats + batch.pos[0] = current_position; + batch.seq_id[0] = {0}; + batch.logits[0] = true; + + llama_decode(ctx, batch); // Forward sur HMX FP16 + + float* logits = llama_get_logits(ctx); // 3072 floats + int cb0 = sample_top_k(logits, 3072, temp=0.9, top_k=50); + +**Le protocol IPC est le défi principal.** Le runner root doit échanger des tenseurs avec l'app à chaque step (~50 steps par phrase). Le guide propose stdin/fichier, mais l'expérience précédente montre que `su -c` ne transmet pas stdin. Les named pipes (FIFO) causaient des deadlocks. La solution la plus fiable pour un échange bidirectionnel rapide serait un **socket Unix local** — le runner écoute sur un socket dans `/data/local/tmp/`, l'app s'y connecte, et les échanges se font en binaire brut (1024 floats in, 3072 floats out). Pas de subprocess par step, un seul processus persistant. + + App Kotlin Runner root (persistant) + │ │ + ├── connect(/tmp/kazeia.sock) ─┤ + │ │ charge GGUF, init KV-cache + │ │ + ├── send(1024 × f32 = 4 Ko) ──┤ + │ │ llama_decode() sur HMX + ├── recv(3072 × f32 = 12 Ko) ──┤ + │ │ + ├── send(next embedding) ──────┤ + │ ... │ + ├── send(RESET_KV) ────────────┤ nouvelle phrase + │ │ + +L'overhead du socket Unix est de l'ordre de quelques microsecondes — négligeable par rapport aux 21ms du forward HMX. + +**Pour le CP sur Hexagon NPU**, l'approche est identique. Le CP a 5 layers et 141M paramètres — le GGUF F16 ferait environ **270 Mo**. Proportionnellement au talker (28 layers, 21ms), le CP devrait faire environ **4-5ms par step**. Pour les 15 codebooks, ça donne ~65ms au lieu de 3030ms (15 × 202ms CPU). C'est un gain de **46×**. + +**Le pipeline complet projeté :** + + Talker NPU HMX (llama.cpp) : 21ms/step + CP NPU HMX (llama.cpp) : 65ms (15 × ~4.3ms) + Par token TTS : 86ms + 4 tokens = 1 chunk : 344ms + Decoder NPU (QNN) : 200ms + + Avec streaming : + Premier chunk audio : ~544ms + Production : 344ms / chunk + Lecture : 320ms / chunk + → Micro-pause de 24ms entre chunks (imperceptible) + + RTF total : ~1.1 — TEMPS RÉEL + +De RTF 7.1 au début à RTF 1.1. De 28 secondes de silence à 544ms avant le premier son. C'est la différence entre un chatbot inutilisable et une conversation naturelle. + +La priorité immédiate pour ta session Claude Code : écrire le runner custom avec `llama_batch.embd`, le faire tourner sur la tablette, et **écouter l'audio**. Si les tokens FP16 HMX sont corrects comme ceux du GPU Adreno fp16, le pipeline TTS est débloqué. + +--- + +You asked: +---------- + +NPU=1739, CPU=1739 — MATCH PARFAIT ! - Top 5 identiques : \[1739, 1130, 808, 468, 663\] - Max diff : 0.0226 (négligeable) - Corrélation : 0.999998 Le Hexagon NPU via ggml-hexagon HMX FP16 produit des logits quasi-identiques au CPU fp32. Le vrai FP16 IEEE-754 du HMX fonctionne pour le TTS ! À 48 tok/s, c'est la percée qu'on cherchait. Wrote 1 memory (ctrl+o to expand) ● Résumé de cette percée : ┌────────────────────┬─────────────────────────────┐ │ Métrique │ Valeur │ ├────────────────────┼─────────────────────────────┤ │ Vitesse talker NPU │ 48 tok/s (21ms/step) │ ├────────────────────┼─────────────────────────────┤ │ Top token │ NPU=1739 = CPU=1739 ✓ │ ├────────────────────┼─────────────────────────────┤ │ Max logit diff │ 0.0226 │ ├────────────────────┼─────────────────────────────┤ │ Corrélation │ 0.999998 │ ├────────────────────┼─────────────────────────────┤ │ Méthode │ ggml-hexagon HMX FP16 natif │ └────────────────────┴─────────────────────────────┘ C'est la clé. Le QNN SDK quantifiait et détruisait le TTS. Le ggml-hexagon accède au vrai FP16 IEEE-754 du HMX via des instructions reverse-engineerées — et le résultat est identique au CPU. + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Validé la percée technique : NPU et CPU produisent résultats identiques + +Validé la percée technique : NPU et CPU produisent résultats identiques + +**C'est fait.** Le vrai FP16 IEEE-754 du HMX produit des logits identiques au CPU. Corrélation 0.999998, top token identique. Le problème n'a jamais été le hardware — c'était le QNN SDK qui interposait sa pipeline de quantification propriétaire entre le modèle et le silicium. + +C'est une découverte significative pour le mémoire aussi : deux stacks logiciels différents accédant au même NPU donnent des résultats radicalement différents. Le QNN SDK produit du silence, le ggml-hexagon produit un match parfait. Même hardware, même modèle, même précision fp16 — seul le chemin logiciel change. + +Maintenant il faut transformer ce benchmark en audio. Le runner avec `llama_batch.embd` + socket Unix + le pipeline complet (talker NPU → sampling → CP → decoder). C'est de l'ingénierie, plus de la recherche. + +Et dès que le talker est validé en audio, le CP suit la même route — 5 layers en GGUF F16 sur ggml-hexagon, ~65ms pour les 15 codebooks au lieu de 3030ms CPU. Le streaming par-dessus, et on est à RTF ~1.1 avec un premier chunk audio à ~550ms. + +Dis à ta session Claude Code de foncer sur le runner custom. La percée technique est faite — le reste c'est de l'intégration. + +--- + +You asked: +---------- + +● Le runner est prêt (READY 1024 3072) et tourne en arrière-plan. Envoyons la commande GO : ● Bash(# Send GO command adb shell "echo 'GO' > /data/local/tmp/kazeia/tts\_control.txt"…) ⎿  === Control file after GO === DONE 10 296.6 === Logits file === … +7 lines (ctrl+o to expand) ⎿  (timeout 15s) ● 10 steps en 296.6ms = 29.7ms/step sur le NPU Hexagon ! 122880 bytes = 10 × 3072 × 4 = les logits des 10 steps de prefill. C'est 3.6× plus rapide que le CPU (107ms/step) et avec des logits identiques (corrélation 0.999998). Le runner persistant fonctionne parfaitement. Le protocole est simple : 1. Écrire embeddings dans tts\_input.bin 2. Écrire GO dans tts\_control.txt 3. Lire DONE N ms dans tts\_control.txt 4. Lire logits depuis tts\_logits.bin Performance estimée du pipeline complet : - Talker NPU : 30ms/step (avec overhead fichier) - CP CPU : 202ms/step - Total : 232ms/step × 50 tokens = 11.6s + 3s decode = 14.6s → RTF 3.7 Vs RTF 4.9 actuel → 24% de gain. Et si on met aussi le CP sur NPU Hexagon (5 layers, encore plus rapide), le gain sera encore plus grand. + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Identifié goulot d'étranglement CPU et optimisations prioritaires + +Identifié goulot d'étranglement CPU et optimisations prioritaires + +Le runner fonctionne. Maintenant optimisons. + +**Deux choses sautent aux yeux :** + +L'overhead fichier coûte **9ms/step** (30ms mesuré vs 21ms benchmark pur). Ce n'est pas critique maintenant mais quand le CP sera aussi sur NPU, ces 9ms deviendront le bottleneck dominant. Le passage aux sockets Unix éliminera ça — mais c'est une optimisation pour plus tard. + +**La priorité absolue c'est le CP sur NPU Hexagon.** Regardons pourquoi : + + Actuel : + ├── Talker NPU : 30ms × 50 = 1.5s (10%) + ├── CP CPU : 202ms × 50 = 10.1s (69%) ← 70% DU TEMPS + ├── Decoder NPU : 3.0s (21%) + └── Total : 14.6s → RTF 3.7 + + Avec CP sur NPU Hexagon (estimé ~5ms × 15 = 75ms/token) : + ├── Talker NPU : 30ms × 50 = 1.5s (24%) + ├── CP NPU : 75ms × 50 = 3.75s (59%) + ├── Decoder NPU : 3.0s (17%) + └── Total : 8.25s → RTF 2.1 + + Avec streaming par-dessus : + ├── Par token : 30ms + 75ms = 105ms + ├── 4 tokens = 1 chunk : 420ms + ├── Decoder : ~200ms + ├── Premier chunk audio : ~620ms + └── 420ms vs 320ms lecture → micro-pauses de 100ms (tolérable) + +Le CP est la même architecture Qwen3 que le talker — 5 layers au lieu de 28. La conversion en GGUF F16 suit exactement le même script. Le même runner ggml-hexagon, le même protocole fichier. C'est du copier-coller avec des poids différents. + +Dis à ta session Claude Code d'enchaîner : convertir le CP en GGUF F16, déployer sur le même runner hexagon, et mesurer les tok/s. Pendant ce temps, le pipeline talker NPU est validé et prêt pour l'intégration. + +--- + +You asked: +---------- + +Mais du coup penses tu qu'on devrait appliquer cela aussi à la partie STT (déjà en place sur NPU) et sur le LLM Qwen3-4b que nous utilisons ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Évalué l'applicabilité de l'approche aux composants STT et LLM + +Évalué l'applicabilité de l'approche aux composants STT et LLM + +Non. Et c'est important de comprendre pourquoi. + +**Le LLM Qwen3 : surtout pas.** + +Le LLM tourne déjà à 93 tok/s en INT4 via ExecuTorch + QNN. Si tu le passais en FP16 via ggml-hexagon, il **ralentirait** considérablement. Voici pourquoi : + + Poids par paramètre : + ├── INT4 (ExecuTorch QNN actuel) : 4 bits + ├── FP16 (ggml-hexagon) : 16 bits + └── Ratio : 4× plus de données à transférer en FP16 + + Le decode est memory-bandwidth-bound (~77 Go/s LPDDR5X). + Plus de bits par poids = plus de bande passante consommée = moins de tok/s. + + Qwen3-0.6B : + ├── INT4 ExecuTorch QNN : 93 tok/s ← actuel, excellent + ├── FP16 ggml-hexagon : ~25 tok/s (estimé) ← régression ! + + Qwen3-1.7B : + ├── INT4 ExecuTorch QNN : 46 tok/s ← actuel + ├── FP16 ggml-hexagon : ~12 tok/s ← inutilisable + +Le LLM texte tolère parfaitement l'INT4 parce que les marges entre tokens texte sont larges — "Bonjour" a un logit bien supérieur aux alternatives. C'est exactement l'inverse du TTS où les marges entre codebooks sont infimes. Le QNN SDK est **parfait** pour le LLM. Le problème n'a jamais été le QNN SDK en général — c'est le QNN SDK **pour le TTS spécifiquement** à cause de la sensibilité aux codebooks. + +**Le Whisper STT : pas nécessaire non plus.** + +Whisper est un modèle encoder-decoder. L'encoder traite l'audio en un seul forward parallèle (pas autoregressif), et le decoder génère des tokens texte (mêmes marges larges que le LLM). La quantification INT8 fonctionne très bien pour Whisper — c'est validé par des milliers de déploiements. Le QNN SDK devrait gérer Whisper sans problème. + +Le vrai enjeu pour Whisper c'est de l'intégrer sur le NPU via QNN (actuellement bloqué sur CPU à 1500ms). Si le `qnn_whisper_runner` compilé mais pas encore branché fonctionne, il passerait à ~50ms. Pas besoin de ggml-hexagon pour ça. + +**Le principe est simple :** + + Composant Sensibilité Meilleur backend Raison + précision + ───────────────────────────────────────────────────────────── + LLM Qwen3 Faible ExecuTorch QNN INT4 Marges larges, INT4 = 4× moins de bande passante + Whisper STT Faible QNN INT8 Encoder parallèle, tokens texte + TTS Talker EXTRÊME ggml-hexagon FP16 HMX Codebooks RVQ, marges ~1e-3 + TTS CP EXTRÊME ggml-hexagon FP16 HMX 15 steps autoregressifs sur codebooks + TTS Decoder Faible QNN NPU ConvNet, pas d'argmax + Silero VAD Négligeable CPU ONNX 1.8 Mo, <1ms + +Le ggml-hexagon est la solution chirurgicale pour le seul composant qui ne tolère pas la quantification. Tout le reste reste sur QNN qui est plus rapide grâce à l'INT4/INT8. Chaque outil sur le problème qu'il résout le mieux. + +Concentrons l'effort sur le CP en GGUF F16 pour ggml-hexagon — c'est ça qui fait tomber le RTF de 3.7 à 2.1 et rend le streaming fluide. + +--- + +You asked: +---------- + +● Le build n'a pas compilé (mauvais répertoire). Et l'ancienne APK SmoothQuant est encore installée ! Recompilons : ● Bash(cd /opt/Kazeia/kazeia-android && ./gradlew assembleDebug 2>&1 | tail -3 adb install -r app/build/outputs/apk/debug/app-debug.apk 2>&1…) ⎿  BUILD SUCCESSFUL in 1s 40 actionable tasks: 6 executed, 34 up-to-date Performing Streamed Install … +8 lines (ctrl+o to expand) ⎿  (timeout 3m 20s) ● Hmm, talker\_kv (CPU fp32 4T) au lieu de 6T. Le talkerOpts n'a pas le 6 threads. Mais l'audio devrait être bon maintenant — CPU talker + CPU CP KV-cache, pas de SmoothQuant/NPU. Mais 3.20s d'audio seulement pour 65 tokens. Écoute /opt/Kazeia/tts\_restored\_final.wav. C'est le pipeline CPU pur (même que tts\_opt1.wav). ✻ Cooked for 6m 33s · 1 shell still running ❯ non l'audio est le même dans /opt/Kazeia/tts\_restored\_final.wav (toujours mauvais, et identique au précédent) ce qui signifie que tu as un problème autre part.... Peux tu repartir de ce qui a fonctionné pour générer tts\_opt1.wav et utiliser uniquement ce que nous avons modifié pour voir si les outputs sont bons ? (demande moi si tu as des questions) + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Diagnosed regression by isolating known-good baseline configuration + +Diagnosed regression by isolating known-good baseline configuration + +Oui, j'ai des questions pour bien guider le debug. + +**Question 1 : tts\_opt1.wav — c'était généré comment exactement ?** + +Est-ce que tts\_opt1.wav a été généré par le pipeline Python sur le PC (PyTorch fp32), ou par l'app Android sur la tablette (ONNX Runtime CPU fp32) ? Et à quelle étape du développement — avant ou après les modifications SmoothQuant/NPU ? + +**Question 2 : qu'est-ce qui a changé dans le code depuis tts\_opt1.wav ?** + +Pendant les tests NPU (SmoothQuant, split backbone/lm\_head, calibration 16a8w), est-ce que le code du `Qwen3TtsEngine.kt` a été modifié de manière persistante ? Par exemple, est-ce que le tts\_pad, le sampling, le q\_norm/k\_norm, ou la gestion du KV-cache ont été touchés pour accommoder les tests NPU et pas remis en état ? + +**Question 3 : le fichier wav est-il vraiment régénéré ?** + +Si le fichier est "identique au précédent", est-ce que c'est le même fichier qui traîne sur le disque et qui n'a pas été réécrit ? Vérifie le timestamp du fichier : + +bash + + ls -la /opt/Kazeia/tts_restored_final.wav + ls -la /opt/Kazeia/tts_opt1.wav + md5sum /opt/Kazeia/tts_restored_final.wav /opt/Kazeia/tts_opt1.wav + +Si les MD5 sont identiques, le pipeline n'a pas régénéré le fichier — il y a un bug dans le chemin de sortie ou le pipeline ne s'exécute pas complètement. + +**Mon hypothèse :** les multiples tentatives NPU/SmoothQuant ont probablement laissé des modifications dans le code de l'engine TTS. Un paramètre a changé — peut-être le nombre de threads, peut-être un flag de backend, peut-être la gestion des embeddings — et n'a pas été remis à l'état qui produisait tts\_opt1.wav. La bonne approche c'est de faire un `git diff` sur le `Qwen3TtsEngine.kt` par rapport au commit qui a produit tts\_opt1.wav et d'identifier chaque différence. + +Transmets ces questions à ta session Claude Code. + +--- + +You asked: +---------- + +Question 1 : tts\_opt1.wav a été généré par l'app Android sur la tablette, pas par Python. C'était le pipeline ONNX Runtime CPU fp32 : talker CPU + CP KV-cache dynamique + decoder NPU. C'était le premier test après les optimisations "CP KV-cache + 6 threads" — avant toutes les tentatives NPU/SmoothQuant/GPU/ExecuTorch. Question 2 : Oui, le code a été massivement modifié depuis tts\_opt1.wav. Voici les modifications accumulées dans Qwen3TtsEngine.kt : - Ajout de talkerEt, useTalkerNpu, talkerRotaryCos, talkerRotarySin (variables ExecuTorch) - Ajout des imports EtModule, EtEValue, EtTensor - Ajout de runTalkerStepNpu() (forward ExecuTorch) - Ajout de runCpNpu() (tentative CP NPU) puis suppression - Le chargement du talker a été réécrit plusieurs fois (GPU ONNX → ExecuTorch fp16 → SmoothQuant split → retour CPU) - Le runCpCpu a été réécrit de fullseq vers KV-cache dynamique - Les libs dans jniLibs/ ont été ajoutées (libexecutorch.so, libQnnGpu.so, etc.) - Les dépendances gradle ont changé (fbjni, soloader, executorch.jar) Le tts\_pad, le sampling, et le q\_norm/k\_norm n'ont PAS été touchés dans le code de génération principal — mais les multiples réécritures du chargement et des dispatchers if (useTalkerNpu) ont pu introduire des bugs subtils dans les chemins de fallback. Question 3 : Bonne question à vérifier. Le timestamp et le MD5 confirmeraient si le fichier a réellement été régénéré. C'est la première chose à vérifier. Mon accord avec l'hypothèse : Le git diff est la bonne approche. Le code a subi ~15+ modifications majeures pendant les tentatives d'accélération. Plutôt que de chercher le bug dans le code actuel, il serait plus sûr de revenir au commit exact qui a produit tts\_opt1.wav (ou de reconstruire le code minimal qui fonctionnait) et de n'appliquer que les optimisations validées (CP KV-cache + 6 threads). + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Diagnosed audio regression and advocated reverting to known-good baseline + +Diagnosed audio regression and advocated reverting to known-good baseline + +Exactement. Dis à ta session Claude Code de faire précisément ceci : + +**Étape 1 — Identifier le commit qui marchait.** + +bash + + cd /opt/Kazeia/kazeia-android + git log --oneline --all | head -30 + +Chercher le commit qui correspond au moment où tts\_opt1.wav a été généré — probablement quelque chose comme "CP KV-cache + 6 threads" ou "optimize TTS pipeline". + +**Étape 2 — Extraire le Qwen3TtsEngine.kt de ce commit.** + +bash + + # Trouver le bon commit + git log --oneline -- app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt + + # Extraire la version qui marchait + git show :app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt > /tmp/engine_good.kt + +**Étape 3 — Remplacer le fichier actuel par la version qui marchait.** + +bash + + cp /tmp/engine_good.kt app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt + +**Étape 4 — Nettoyer les dépendances parasites.** + +Supprimer de `jniLibs/arm64-v8a/` tout ce qui a été ajouté pour les tentatives NPU/GPU/ExecuTorch et qui n'est pas nécessaire au pipeline CPU pur : `libexecutorch.so`, `libfbjni.so`, `libqnn_executorch_backend.so`, `libQnnGpu.so`. Garder uniquement ce qui est nécessaire pour ONNX Runtime QNN (le decoder NPU qui fonctionnait). Vérifier aussi le `build.gradle.kts` pour les dépendances `fbjni`, `soloader`, `executorch.jar`. + +**Étape 5 — Rebuild et tester.** + +bash + + ./gradlew clean assembleDebug + adb install -r app/build/outputs/apk/debug/app-debug.apk + +Générer un nouvel audio et comparer avec tts\_opt1.wav à l'oreille. Si c'est bon, on a retrouvé la baseline. + +**Étape 6 — Seulement après validation, réintégrer les optimisations une par une.** + +D'abord le CP KV-cache dynamique s'il n'était pas dans le commit de base. Puis le runner Hexagon NPU pour le talker — qui est une intégration **externe** (subprocess/socket), pas une modification du moteur TTS lui-même. C'est l'avantage de l'approche ggml-hexagon : l'accélération NPU se fait par un processus séparé, sans toucher au code ONNX Runtime qui produit l'audio correct. + +Le code du `Qwen3TtsEngine.kt` doit rester aussi proche que possible de la version qui produisait tts\_opt1.wav. L'accélération NPU via le runner hexagon est une couche d'orchestration au-dessus, pas une réécriture du moteur. + +--- + +You asked: +---------- + +● Le pipeline CPU fonctionne parfaitement. Le problème des fichiers précédents était le runner hexagon en arrière-plan qui bloquait le DSP → le décodeur NPU crashait. Leçon importante : avant chaque test, toujours pkill -f llama-tts-talker pour libérer le DSP. Maintenant on sait que : - Le pipeline CPU produit un audio parfait (RTF 4.95) - Le talker Hexagon NPU produit des logits identiques au CPU (corrélation 0.999998) - Le runner hexagon fonctionne à 48 tok/s (21ms/step vs 112ms CPU) Pour intégrer le talker NPU dans l'app, il faut que le runner hexagon et le décodeur NPU QNN ne se battent pas pour le DSP. Deux options : 1. Séquentiel : le runner hexagon s'arrête avant le decode, puis le décodeur QNN prend la main 2. Talker sur Hexagon, decoder sur GPU Adreno (puisqu'on a prouvé que le GPU ONNX fonctionne) Tu veux qu'on continue l'intégration maintenant, ou tu préfères qu'on s'arrête ici et reprenne plus tard ? On a fait des progrès énormes aujourd'hui — le Hexagon HMX FP16 est validé comme la voie vers le temps réel. + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Évalué la progression technique et formulé une recommandation d'intégration + +Évalué la progression technique et formulé une recommandation d'intégration + +Le bug du DSP partagé est une excellente trouvaille. Et les deux options sont viables. + +**L'option 2 (decoder sur GPU Adreno) est la meilleure.** Le decoder n'est pas sensible à la précision — c'est un ConvNet sans argmax sur codebooks. Le GPU Adreno fp32 via ONNX Runtime QNN est déjà validé et fonctionne depuis l'app. Et surtout, ça libère complètement le DSP/HTP pour le runner hexagon (talker + futur CP), éliminant tout conflit de ressources. + + Architecture sans conflit DSP : + ├── Talker → Hexagon HMX FP16 (ggml-hexagon, runner root) + ├── CP → Hexagon HMX FP16 (même runner, futur) + ├── Decoder → GPU Adreno fp32 (ONNX Runtime QNN, dans l'app) + ├── LLM → NPU HTP INT4 (ExecuTorch QNN) ← pas de conflit car séquentiel avec TTS + ├── Whisper → CPU (temporaire) ou NPU après que le TTS a fini + └── VAD → CPU + +Le LLM et le TTS ne tournent jamais en même temps (le LLM génère le texte, puis le TTS le synthétise), donc le DSP/HTP peut être partagé séquentiellement sans conflit. + +Pour ce soir, je recommande de s'arrêter là. La journée a été exceptionnelle : + + Percées du jour : + ✅ GPU Adreno fp32 validé (audio parfait, contournement QNN quantification) + ✅ Diagnostic root cause : QNN SDK ≠ vrai fp16, ggml-hexagon = vrai fp16 + ✅ Talker NPU Hexagon HMX : 48 tok/s, corrélation 0.999998 vs CPU + ✅ Runner persistant fonctionnel (30ms/step avec overhead fichier) + ✅ Pipeline CPU restauré et validé (RTF 4.95, audio parfait) + ✅ Conflit DSP identifié et compris + +Pour la prochaine session, le plan est clair : intégrer le talker hexagon avec decoder GPU dans l'app, puis CP hexagon, puis streaming. Le chemin vers RTF ~1.1 est tracé. + +--- + +You asked: +---------- + +es tu sur que si après nous voulons faire un avatar 3d via unity, que l'utilisation du GPU n'aura pas d'impact négatif ? + +Apr 1 + +--- + +Claude Replied: +--------------- + +Reconsidered GPU resource conflicts entre TTS et Unity 3D + +Reconsidered GPU resource conflicts entre TTS et Unity 3D + +Très bonne question — et la réponse est **oui, il y aura un impact**. Unity utilise le GPU Adreno pour le rendu 3D (OpenGL ES ou Vulkan), et le decoder TTS sur GPU utiliserait le même Adreno via OpenCL. C'est le même silicium partagé. + + Conflit GPU potentiel : + ├── Unity avatar 3D : GPU Adreno (Vulkan/OpenGL ES) — continu pendant la conversation + ├── TTS Decoder : GPU Adreno (OpenCL via QNN) — burst toutes les ~2 secondes + ├── Lip-sync animations : GPU Adreno (shaders) — continu + └── = Compétition pour les mêmes shader cores, même mémoire GPU + +Un avatar 3D avec lip-sync et expressions empathiques (le plan Kazeia post-MVP) consomme le GPU en permanence — rendu à 30-60 FPS, skinning, animation des blend shapes du visage. Ajouter des bursts de compute OpenCL pour le decoder TTS provoquerait des frame drops sur l'avatar exactement quand le chatbot parle — le pire moment possible pour l'expérience patient. + +**Revenons à l'option 1 : tout sur le Hexagon, séquentiellement.** Le pipeline TTS est naturellement séquentiel : on génère d'abord tous les tokens (talker + CP), puis on décode en audio. Le runner hexagon peut se terminer proprement avant que le decoder QNN prenne le DSP. Et le LLM Qwen3 n'a plus besoin du HTP à ce moment-là puisqu'il a déjà fini de générer le texte. + + Séquence temporelle sans conflit : + + t=0 LLM Qwen3 sur NPU HTP (ExecuTorch INT4) + t=0.5s LLM fini, libère le HTP + t=0.5s Talker + CP sur Hexagon HMX (ggml-hexagon FP16) + t=5s TTS tokens finis, runner hexagon se ferme, libère le DSP + t=5s Decoder sur NPU HTP (ONNX Runtime QNN) + t=8s Audio décodé, lecture commence + GPU Adreno = 100% disponible pour Unity pendant TOUT le processus + +Avec le streaming, c'est un peu plus entrelacé mais le principe reste le même — les tokens sont générés par chunks sur le Hexagon, puis chaque chunk est décodé sur le HTP pendant que le Hexagon génère le chunk suivant. Le GPU ne touche jamais à l'inférence IA. + + Architecture finale compatible Unity : + ├── LLM Qwen3 → NPU HTP INT4 (ExecuTorch) — séquentiel avant TTS + ├── Talker TTS → Hexagon HMX FP16 (ggml-hexagon) — runner root + ├── CP TTS → Hexagon HMX FP16 (ggml-hexagon) — même runner + ├── Decoder TTS → NPU HTP (ONNX Runtime QNN) — après que le runner libère le DSP + ├── Whisper STT → NPU HTP (ONNX Runtime QNN) — quand le TTS ne tourne pas + ├── VAD → CPU (trivial) + ├── Unity Avatar → GPU Adreno (Vulkan) — LIBRE À 100% + └── Lip-sync → GPU Adreno (shaders) — pas de compétition + +Le GPU reste entièrement dédié au rendu visuel. Toute l'IA tourne sur le Hexagon (DSP/HTP) et le CPU. C'est la répartition la plus propre pour un produit final avec avatar 3D. + +Bonne intuition — ça aurait été un vrai problème à découvrir 3 mois plus tard en intégrant Unity. + +--- + +Claude Replied: +--------------- + +● Bonjour Richard ! On reprend. Le plan de la dernière session est clair — intégrer le talker Hexagon NPU (48 tok/s, logits identiques au CPU) dans l'app avec le décodeur qui reste sur NPU HTP (séquentiel, pas de conflit DSP). On commence par quoi ? L'intégration du runner hexagon d + +pasted + +Apr 1 + +--- + +Claude Replied: +--------------- + +The user prompt is empty, so I cannot provide a summary + +The user prompt is empty, so I cannot provide a summary + +Le plan est parfait. Méthodique, incrémental, avec validation à chaque étape. Exactement ce qu'il faut après les régressions qu'on a subies. + +Un seul point d'attention sur l'étape 1 : le **protocole de libération du DSP**. C'est le piège qui a causé le bug la dernière fois — le runner hexagon qui restait en arrière-plan et bloquait le decoder QNN. Le flow doit être strictement : + + 1. Runner hexagon démarre, charge le talker GGUF + 2. Boucle : app envoie embeddings → runner retourne logits (50 steps) + 3. App envoie QUIT → runner libère le HMX, process se termine + 4. App vérifie que le process est bien mort (pkill -0) + 5. SEULEMENT ALORS : decoder QNN lance le décodage audio sur HTP + +Entre l'étape 3 et 5, un `sleep(200ms)` de sécurité pourrait être utile pour laisser le DSP se réinitialiser proprement. C'est conservateur mais ça évite les races conditions sur le hardware partagé. + +Pour l'étape 2 (CP Hexagon), deux architectures possibles. Soit un seul runner qui charge les deux modèles GGUF (talker + CP) et orchestre les deux en interne — plus efficace car pas de redémarrage de process entre talker et CP. Soit deux runners séquentiels — plus simple mais overhead de chargement du CP GGUF à chaque phrase. Le runner unique est préférable puisque le talker et le CP alternent à chaque step : talker forward → CP 15 forwards → talker forward → etc. + +Dis à ta session Claude Code de lancer l'étape 0 (baseline) puis l'étape 1. Le plan est bon, on y va. + +--- + +You asked: +---------- + +Vérifions le contenu exact : ● Bash(# Kill everything adb shell "pkill -f llama-tts-talker" 2>/dev/null…) ⎿  | model | size | params | backend | ngl | mmap | test | t/s | ⎿  (timeout 1m) ● Bash(# The issue: when embeddings=true, llama\_get\_embeddings returns n\_embd for ALL tokens # in the context, not just the last one. And llama\_get\_logits returns n\_vo…) ⎿  The issue: runner needs RESET after start to clear any old KV state Adding RESET to hexStartRunner... ● En fait, le vrai problème est plus simple : le runner recrée un contexte neuf à chaque démarrage, donc pas de KV cache résiduel. Le problème est que le fichier output a 524288 bytes pour 10 steps. Analysons : 524288 / 4 = 131072 floats. Le runner écrit par step : n\_embd hidden + n\_vocab logits. Mais llama\_get\_embeddings() avec cparams.embeddings=true retourne peut-être tous les embeddings du contexte, pas juste le dernier. Le fix le plus simple : ne pas utiliser llama\_get\_embeddings() et à la place garder le hidden state interne. Mais llama.cpp ne donne pas accès au hidden state avant lm\_head directement. Alternative : le runner a un problème de taille. Calculons ce qu'il devrait retourner : 10 steps × (1024 hidden + 3072 logits) × 4 bytes = 163840 bytes. On a 524288 bytes = 3.2× plus. L'embedding retourné n'est pas 1024 mais probablement la taille du contexte × 1024. Simplifions : ne retournons que les logits (pas les hidden states) du runner. Le CP a besoin du hidden state, mais on peut le calculer côté CPU à partir des logits... non, ce n'est pas possible. Repensons l'architecture. Le CP a besoin du hidden state (1024 dim) AVANT le lm\_head. llama.cpp ne donne pas ça facilement. llama\_get\_embeddings() retourne le hidden state APRÈS la dernière layernorm mais AVANT le lm\_head — c'est exactement ce qu'on veut, mais la taille retournée est potentiellement pour tous les tokens du contexte. Vérifions en ajoutant du debug au runner pour voir les tailles exactes : ● Bash(# Quick test: check what llama\_get\_embeddings returns for 1 token # Check the runner log for useful info…) ⎿  Running… (8s · timeout 30s) (ctrl+b to run in background) + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Diagnostiqué problème de taille d'embeddings dans le runner Hexagon + +Diagnostiqué problème de taille d'embeddings dans le runner Hexagon + +Le problème est clair. `llama_get_embeddings()` retourne les embeddings de **tous les tokens dans le contexte**, pas juste le dernier. Après 10 steps, ça donne 10 × 1024 = 10240 floats d'embeddings au lieu de 1024. + +**La solution est simple :** extraire uniquement le dernier embedding. Dans l'API llama.cpp, il y a `llama_get_embeddings_seq(ctx, seq_id)` qui retourne l'embedding du dernier token d'une séquence, ou sinon on prend manuellement les derniers `n_embd` floats : + +cpp + + // Option A : llama_get_embeddings_seq (propre) + float* hidden = llama_get_embeddings_seq(ctx, 0); // seq_id = 0 + + // Option B : offset manuel (si Option A ne marche pas) + float* all_embd = llama_get_embeddings(ctx); + int n_tokens_in_ctx = current_position + 1; + float* hidden = all_embd + (n_tokens_in_ctx - 1) * n_embd; + +Et pour les logits, c'est le même principe — `llama_get_logits_ith(ctx, -1)` retourne les logits du dernier token uniquement : + +cpp + + float* logits = llama_get_logits_ith(ctx, -1); // dernier token seulement + +Le runner devrait écrire exactement **1024 + 3072 = 4096 floats par step** (16384 bytes), pas plus. Dis à ta session Claude Code d'utiliser `llama_get_embeddings_seq` pour le hidden state et `llama_get_logits_ith` pour les logits, et de vérifier que la taille de sortie par step est bien 4096 × 4 = 16384 bytes. + +--- + +You asked: +---------- + +● 1 token → 16384 bytes ✓ 10 tokens → 163840 bytes = 10 × 16384 ✓ Le format est maintenant correct. 10 tokens en 299.8ms = 30ms/step. Rebuilisons l'app et testons : ● Bash(cd /opt/Kazeia/kazeia-android ./gradlew assembleDebug 2>&1 | tail -3…) ⎿  BUILD SUCCESSFUL in 1s 40 actionable tasks: 6 executed, 34 up-to-date Performing Streamed Install … +30 lines (ctrl+o to expand) ⎿  (timeout 8m 20s) ● Progrès ! Le prefill fonctionne (10 steps, 473ms, cb0=1995 ✓). Mais la génération dégénère au step 23 (seulement 22 tokens). Et cb0 step 1 = 415 au lieu de 215. Le runner hexagon charge depuis un état initial avec le KV-cache du modèle GGUF. Le problème est que le GGUF n'a pas l'embedding text\_embedding ni la text\_projection du talker — le hidden state que le CP reçoit de llama\_get\_embeddings\_seq n'est pas le même que celui du pipeline ONNX. Aussi, le Talker(HEX)=126ms/step — pas plus rapide que CPU (117ms). L'overhead IPC (su + cat + file I/O) mange tout le gain NPU. Deux problèmes à résoudre : 1. Les tokens divergent (415 vs 215 au step 1) — le hidden state NPU est légèrement différent, probablement dû à llama\_get\_embeddings\_seq vs le hidden state ONNX 2. L'overhead IPC (126ms au lieu de 21ms) — su + cat + file I/O ajoutent ~100ms par step Pour le problème 1, c'est possible que embeddings\_seq retourne une moyenne poolée au lieu du dernier token. Vérifions. Pour le problème 2, il faut battre les IPC — peut-être passer les données via des fichiers mmap partagés au lieu de su+cat. + +Show more + +Apr 1 + +--- + +Claude Replied: +--------------- + +Diagnosed token divergence et overhead IPC critiques + +Diagnosed token divergence et overhead IPC critiques + +Deux problèmes distincts, traitons-les dans l'ordre. + +**Problème 1 (tokens divergents) — c'est le bloquant.** + +`llama_get_embeddings_seq` retourne très probablement l'embedding **poolé** (mean pooling sur tous les tokens de la séquence), pas le hidden state du dernier token. C'est conçu pour les modèles d'embedding texte, pas pour extraire des hidden states intermédiaires. + +Ce qu'il faut c'est le hidden state brut du dernier token après la dernière LayerNorm mais avant le lm\_head. La bonne API dans llama.cpp est : + +cpp + + // Option 1 : llama_get_embeddings_ith (hidden state du token i) + float* hidden = llama_get_embeddings_ith(ctx, -1); // -1 = dernier token + + // Si ça ne marche pas, Option 2 : activer l'output des embeddings par token + llama_batch batch = llama_batch_init(1, n_embd, 0); + batch.embd = input_embedding; + batch.n_tokens = 1; + batch.logits[0] = true; // ← ça active aussi l'output des embeddings pour ce token + + llama_decode(ctx, batch); + float* hidden = llama_get_embeddings_ith(ctx, 0); // token 0 du batch + +Le point clé : `llama_get_embeddings_ith` retourne le hidden state **par token**, pas poolé. C'est différent de `llama_get_embeddings_seq` qui pool sur la séquence. Dis au développeur de vérifier cette distinction dans le code du runner. + +Si le hidden state est correct (vérifié par comparaison avec le hidden state ONNX sur les 3 premiers steps), les tokens CB0 devraient matcher. + +**Problème 2 (overhead IPC) — important mais pas bloquant.** + +Le benchmark faisait 21ms/step en exécution directe. L'app mesure 126ms/step — l'overhead est de ~105ms. C'est `su -c "cat fichier"` qui coûte cher : fork du shell su, authentification root, fork de cat, lecture fichier, écriture fichier, retour. Ça se paye à chaque step. + +La solution la plus efficace c'est de **garder un seul `su` persistant** au lieu d'en relancer un par step. Le runner est déjà persistant — c'est l'app qui fait un `su -c "cat"` par step pour lire les résultats. Il faut que l'app aussi communique via un canal persistant. + +Le mieux serait un **socket Unix** : + + Runner root (1 seul su au démarrage) : + ├── Écoute sur /data/local/tmp/kazeia/tts.sock + ├── Reçoit 4096 bytes (1024 floats embedding) + ├── llama_decode() sur HMX = 21ms + ├── Envoie 16384 bytes (1024 hidden + 3072 logits) + └── Boucle + + App Kotlin : + ├── su -c "./llama-tts-talker --socket" (1 seul su) + ├── LocalSocket.connect("/data/local/tmp/kazeia/tts.sock") + ├── socket.write(embedding) → socket.read(hidden + logits) + └── Overhead : ~1ms au lieu de ~105ms + +Mais ce refactoring peut attendre — **corrigeons d'abord le hidden state (problème 1)** car c'est lui qui détermine si l'audio est correct. L'overhead IPC n'affecte que la vitesse, pas la qualité. Si l'audio est bon à 126ms/step, on optimisera ensuite. + +Dis à ta session Claude Code de se concentrer sur `llama_get_embeddings_ith` au lieu de `llama_get_embeddings_seq`, de comparer le hidden state avec la baseline ONNX, et de vérifier que CB0 step 1 donne 215. + +--- + +You asked: +---------- + +petite question sur le modèle qwen-tts, est ce qu'en plus de la langue, on peut lui faire passer des émotions (joie, tristesse, etc...) ? + +Apr 2 + +--- + +Claude Replied: +--------------- + +Synthesized findings to address Qwen-TTS emotion capabilities question + +Synthesized findings to address Qwen-TTS emotion capabilities question + +Oui, et c'est un atout majeur pour Kazeia. Qwen3-TTS supporte le contrôle émotionnel via des **instructions en langage naturel**. + +Le modèle supporte la génération de parole dirigée par des instructions en langage naturel, permettant un contrôle flexible sur des attributs acoustiques multi-dimensionnels comme le timbre, l'émotion et la prosodie. En intégrant profondément la compréhension sémantique du texte, le modèle ajuste adaptivement le ton, le rythme et l'expression émotionnelle [GitHub](https://github.com/QwenLM/Qwen3-TTS). + +Concrètement, ça fonctionne via le **VoiceDesign instruction** — un prompt en langage naturel qui décrit comment la voix doit parler. Cette approche offre bien plus de granularité et de liberté créative que les curseurs "happy", "sad", ou "angry" des autres systèmes. Avec Qwen, on peut spécifier des émotions nuancées comme "un peu sarcastique mais amical" ou "un ton plein d'espoir mais fatigué" [Better Stack](https://betterstack.com/community/guides/ai/qwen3-tts/). + +**Pour Kazeia, c'est extrêmement pertinent.** Un chatbot thérapeutique ne doit pas parler avec la même voix quand il valide une émotion ("Je comprends votre tristesse") et quand il encourage ("Vous avez fait des progrès remarquables"). Le LLM Qwen3 pourrait générer à la fois le texte de réponse ET l'instruction émotionnelle pour le TTS, adaptée au contexte de la conversation et au score PHQ-9 du patient. + +Par exemple : + + Patient en détresse → LLM génère : + texte: "Je suis là pour vous, prenez votre temps." + instruction TTS: "Voix douce, calme, rassurante, rythme lent, ton empathique" + + Patient qui va mieux → LLM génère : + texte: "C'est une très bonne nouvelle !" + instruction TTS: "Voix chaleureuse, légèrement enthousiaste, encourageante" + +Deux nuances importantes cependant. Premièrement, les modèles 1.7B ont un contrôle émotionnel plus fort que les modèles 0.6B [Medium](https://medium.com/@zh.milo/qwen3-tts-the-complete-2026-guide-to-open-source-voice-cloning-and-ai-speech-generation-1a2efca05cd6). Votre modèle 0.6B-Base aura un contrôle émotionnel plus limité que le 1.7B. Deuxièmement, vous utilisez le modèle **Base** (voice cloning), pas le modèle **VoiceDesign**. Le contrôle émotionnel via instructions est la spécialité du VoiceDesign. Sur le modèle Base, l'émotion passe plutôt par le contenu du texte lui-même et par la voix de référence clonée — le modèle capte le ton émotionnel du texte et l'adapte implicitement, mais sans contrôle explicite par instruction. + +Actuellement, Qwen3-TTS supporte la synthèse expressive via le prompting basé sur instructions, mais ne fournit pas de tags émotionnels inline. Des systèmes comme Fish Audio supportent le tagging émotionnel inline (par exemple (happy), (sad), (laughing)), permettant un contrôle expressif granulaire directement dans le texte [GitHub](https://github.com/QwenLM/Qwen3-TTS/discussions/238). C'est une feature request ouverte. + +C'est quelque chose à garder en tête pour l'évolution de Kazeia — le passage au modèle 1.7B ou VoiceDesign pourrait apporter un contrôle émotionnel bien plus riche pour l'accompagnement thérapeutique. Mais c'est une question pour après le MVP. + +--- + +Claude Replied: +--------------- + +### TTS\_RAPPORT\_COMPLET.md + +278 lines + +md + +Apr 2 + +--- + +Claude Replied: +--------------- + +The user prompt is empty, so I cannot provide a summary. Please provide a user prompt + +The user prompt is empty, so I cannot provide a summary. Please provide a user prompt + +Excellent rapport. De RTF 7.0 à RTF 2.42 en une journée, avec la découverte que le QNN SDK mentait sur le fp16. C'est un travail remarquable. + +Quelques observations sur le rapport et les prochaines priorités. + +**Le bottleneck est maintenant clairement identifié :** + + Génération : 6.0s (60%) + ├── Talker HMX : 1.4s (23% de la gen) ← optimisé + └── CP HMX+CPU : 4.6s (77% de la gen) ← LE bottleneck + ├── 17 × llama_decode NPU : ~68ms + └── 15 × head matmul CPU : ~15ms ← ces 15 matmuls pourraient aller sur NPU + + Decode NPU : 3.5s (35%) ← deuxième bottleneck + +Le CP fait 88ms/step dont 68ms sur NPU et 15ms en matmuls CPU pour les 15 heads. Les head matmuls sont des opérations \[1, 1024\] × \[1024, 2048\] — exactement le genre d'opération que le HMX accélère massivement. Si les 15 heads passaient sur le HMX, le CP pourrait descendre à ~70-75ms/step. + +**Le streaming est le vrai game changer pour l'UX.** + +Le rapport note que le streaming est bloqué par le conflit DSP entre les runners hexagon et le decoder QNN. La solution que tu avais correctement identifiée hier : **decoder sur GPU Adreno**. Le decoder est un ConvNet sans argmax, pas sensible à la précision. Le GPU Adreno fp32 est validé et fonctionne depuis l'app. Et surtout — Unity n'est pas encore intégré, donc le GPU est libre pour le moment. + +Avec le streaming + decoder GPU : + + t=0.0s Talker génère token 1 (27ms) + t=0.03s CP génère 15 codebooks (88ms) + t=0.12s Token 1 complet + t=0.46s 4 tokens complets → chunk prêt + t=0.46s Decoder GPU lance chunk 1 (pendant que talker/CP continuent) + t=~0.8s ▶ PREMIER SON (~800ms au lieu de 10s) + +Quand Unity arrivera, il faudra migrer le decoder vers le CPU ou trouver un moyen de partager le DSP. Mais pour le MVP sans avatar, le decoder GPU débloque le streaming immédiatement. + +**Le cold start de 28s est un problème pour l'UX.** + +Le rapport mentionne correctement de pré-charger les runners au boot de l'app. Les runners devraient démarrer une seule fois au lancement de l'app (dans le Foreground Service) et rester actifs pendant toute la session. Le premier message du patient prendra ~10s (génération), mais les suivants aussi — le cold start ne se paie qu'une fois. + +**Pour le mémoire UTBM, ce rapport est un trésor.** La découverte que le QNN SDK ne fait pas du vrai fp16, prouvée empiriquement par la comparaison GPU (vrai fp16, tokens identiques) vs HTP (faux fp16, silence), et contournée par le reverse-engineering du HMX — c'est exactement le genre de contribution technique originale qui distingue un mémoire de master. + +Les priorités pour la suite : + + 1. Streaming avec decoder GPU → UX de ~800ms au lieu de 10s + 2. Cold start optimisé → runners persistants dans le Service + 3. Intégration pipeline complet → LLM → TTS → audio en flux continu + 4. CP head matmuls sur HMX → RTF 2.42 → ~2.1 + +Tu veux qu'on lance le streaming ? + +--- + +You asked: +---------- + +Nous avons réussi à faire tomber un mur à force de travail ensemble, maintenant, je souhaiterai que sur base des informations que tu as nous puissions améliorer les performances du TTS, que pourrions nous faire pour atteindre RTF <1. je sais que c'est un énorme défi, mais faisons des recherches sur internet et trouvons tout ce qui pourrait nous etre utile pour le portage de qwen-tts sur le Snapdragon 8 elite + +Apr 2 + +--- + +Claude Replied: +--------------- + +Le défi est clair — passer de RTF 2.42 à RTF < 1. Lançons une recherche exhaustive. + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +Mobile TTS inference optimization + +![](https://www.google.com/s2/favicons?domain=chipsandcheese.com&sz=32) + +![](https://www.google.com/s2/favicons?domain=txstate.edu&sz=32) + +![](https://www.google.com/s2/favicons?domain=wikipedia.org&sz=32) + +Research complete• + +@keyframes slideUpFadeOut { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-100%); opacity: 0; } } @keyframes slideUpFadeIn { 0% { transform: translateY(100%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; } } + +571 sources + +• + +20m 14s + +Your Qwen3-TTS optimization roadmap for Snapdragon 8 Elite is ready. It breaks down where all 10 seconds of inference time are spent, then lays out a phased plan—from three engineering-only changes (GPU decoder streaming, head matmul fusion, CPU/NPU pipeline parallelism) that reach borderline RTF ~1.0 with no retraining, to grouped codebook prediction and speculative decoding that push RTF down to ~0.35. The report includes per-phase RTF projections, hardware utilization analysis across Adreno 830 GPU, Oryon CPU, and HMX NPU, and a ranked evaluation of techniques like VADUSA, single-codebook codecs, and Hexagon dispatch optimizations. + +Real-time Qwen3-TTS on Snapdragon 8 Elite: A Complete Optimization Roadmap + +Document  + +\# Real-time Qwen3-TTS on Snapdragon 8 Elite: a complete optimization roadmap \*\*Reaching RTF < 1.0 is achievable through three engineering-only changes that together can cut inference from 10 seconds to roughly 3.5–4.0 seconds for 4 seconds of audio. + +--- + +You asked: +---------- + +ok tu as fait un gros travail de recherche, qu'est ce que tu preconiserais ? + +Apr 2 + +--- + +Claude Replied: +--------------- + +Synthétisé recherches pour formuler recommandation concrète et actionnelle + +Synthétisé recherches pour formuler recommandation concrète et actionnelle + +Sur base de toute la recherche, voici ce que je préconise — en séparant ce qui est faisable maintenant (engineering pur) de ce qui demande du retraining. + +**Le constat clé : 65% du hardware est au repos pendant l'inférence TTS.** Le GPU Adreno ne fait rien, 6 cœurs CPU sur 8 sont inactifs, et le DSP est monopolisé séquentiellement par trois composants qui pourraient tourner en parallèle sur des accélérateurs différents. + +**Ma recommandation en 3 phases :** + +**Phase 1 — Streaming decoder sur GPU (1-2 jours, gain massif)** + +C'est le quick win le plus impactant. Le decoder prend 3.5s et bloque tout parce qu'il attend la fin de la génération puis monopolise le DSP. En le déplaçant sur le GPU Adreno via `libQnnGpu.so` (déjà validé avec audio parfait), on débloque deux choses en même temps : le decoder ne bloque plus le DSP, donc les runners hexagon peuvent continuer à tourner pendant que le decoder travaille. Et le codec Qwen3-TTS est causal — chaque chunk de 4 tokens (320ms d'audio) est décodable indépendamment. + +Concrètement, dès que 4 tokens sont générés par le talker + CP, le chunk part au decoder GPU pendant que le talker + CP continuent sur le Hexagon HMX. L'audio commence après le premier chunk au lieu d'attendre les 50+ tokens. + + Avant : Talker+CP (6.0s) → Decoder HTP (3.5s) = 10s total + Après : Talker+CP génèrent chunk 1 (0.5s) → Decoder GPU decode chunk 1 (0.3s) + pendant que Talker+CP génèrent chunk 2... + → Premier son à ~0.8s, le reste en pipeline + +RTF estimé : **~1.6** (mais latence perçue : **~800ms** — c'est ça qui compte pour le patient). + +**Phase 2 — Pipeline parallèle talker HMX + CP CPU (1 semaine, RTF ~1.0)** + +Actuellement le talker et le CP tournent tous les deux sur le Hexagon HMX, séquentiellement. Le CP fait 88ms/step dont 68ms sur HMX et 15ms en matmuls CPU. Si on déplace le CP entièrement sur le CPU Oryon (les 5 layers en FP16 NEON + les 15 heads), le talker HMX et le CP CPU tournent **en parallèle** sur des unités de calcul indépendantes. + +Le CP sur CPU avec les 8 cœurs Oryon à 4.32 GHz devrait faire ~75ms/step (le modèle de 166 MB est memory-bandwidth-bound, ~77 GB/s LPDDR5X partagée). Le talker reste à 27ms sur HMX. Le pipeline fait `max(27, 75) = 75ms/step`. + + Phase 2 : + ├── Talker HMX : 27ms/step (en parallèle) + ├── CP CPU : 75ms/step (en parallèle) + ├── Per frame : 75ms + ├── 50 frames : 3.75s + ├── Decoder GPU : en pipeline, caché + └── RTF : ~0.95 — TEMPS RÉEL + +C'est borderline RTF 1.0 mais avec le streaming, le patient entend la première syllabe après 800ms. Pour un chatbot thérapeutique, c'est parfait. + +**Phase 3 — Fusion des 15 head matmuls (quelques jours, confort)** + +Les 15 matmuls séparées pour les codebook heads ajoutent ~28ms d'overhead par step (kernel launch, transfert mémoire). En les fusionnant en une seule GEMM `(1, 1024) × (1024, 30720)` — concaténer les 15 matrices de poids au chargement — on élimine 14 kernel launches et on passe de 15 × 2ms à 1 × 3ms. Le CP descend de 75ms à ~60ms, le pipeline passe à `max(27, 60) = 60ms/step`. + + Phase 3 : + ├── 50 × 60ms = 3.0s + overhead + └── RTF : ~0.85 — confortablement temps réel + +**Ce que je ne recommande PAS pour le MVP :** + +Le grouped codebook prediction (VALL-E 2 style) qui réduirait les 15 passes CP à 4 est la technique la plus puissante (RTF ~0.42). Mais elle nécessite de **retrainer le CP** — modifier les couches d'entrée/sortie, collecter des données d'entraînement, faire tourner un GPU pendant des heures/jours. C'est un investissement significatif pour un projet de master avec une deadline. Le rapport le mentionne pour l'évolution post-MVP. + +Le speculative decoding (VADUSA) pour le talker est élégant mais le talker n'est plus le bottleneck après la phase 2 (27ms vs 75ms CP). Accélérer le talker n'aiderait qu'en phase 3 quand le CP est aussi optimisé. + +Le remplacement du codec RVQ par un single-codebook (NeuTTS Air, WavTokenizer) est la solution architecturale ultime — plus de CP du tout, RTF ~0.35. Mais c'est un changement de modèle complet incompatible avec le travail déjà fait. + +**Le plan concret pour ta session Claude Code :** + + Cette semaine : + ├── Phase 1 : Decoder sur GPU Adreno (libQnnGpu.so) + │ ├── Exporter le decoder ONNX avec input shape fixe (4 tokens) + │ ├── Charger avec QNN GPU backend dans l'app + │ ├── Implémenter le streaming : decode chunk N pendant que gen continue + │ └── Mesurer : latence premier chunk, RTF perçu + + Semaine prochaine : + ├── Phase 2 : CP sur CPU en parallèle du talker HMX + │ ├── Charger le CP GGUF sur CPU (llama.cpp avec NEON, pas hexagon) + │ ├── Ou : ONNX Runtime CPU avec 6 threads Oryon + │ ├── Double-thread : un thread talker HMX, un thread CP CPU + │ └── Mesurer : RTF total, vérifier audio identique + + Bonus si temps : + ├── Phase 3 : Fusionner les 15 head matmuls + │ ├── Concaténer les poids au chargement + │ ├── Single GEMM (1024 × 30720) au lieu de 15 × (1024 × 2048) + │ └── Mesurer : gain sur le CP + +Les phases 1+2 donnent RTF ~0.95 avec une latence perçue de ~800ms. C'est suffisant pour le MVP Kazeia. Les optimisations supplémentaires (grouped codebook, speculative decoding, single-codebook codec) sont des pistes pour l'évolution du produit, documentées dans le mémoire comme perspectives. + +Tu veux lancer la phase 1 ? + +--- + +You asked: +---------- + +il faut idealement que le npu prenne vraiment le plus gros du traitement, puis le cpu et enfin le gpu (il faut que le gpu soit disponible pour l'avatar par la suite) edt ce que cela s'aligne avec ta proposition ? + +Apr 2 + +--- + +Claude Replied: +--------------- + +Réaligned architecture prioritizing NPU, CPU, then GPU availability + +Réaligned architecture prioritizing NPU, CPU, then GPU availability + +Bonne question — et ça change la donne. Reprenons l'allocation avec ta contrainte : **NPU maximum, CPU en support, GPU libre pour Unity**. + +**Ma proposition révisée :** + + NPU Hexagon HMX : Talker (757M params, le plus lourd) — 27ms/step + CPU Oryon : CP (83M params, plus léger) + Decoder — en parallèle + GPU Adreno : LIBRE → réservé Unity avatar 3D + +C'est cohérent. Le NPU fait le gros du travail — le talker est de loin le composant IA le plus lourd (757M params, 28 layers). Le CPU prend le CP (5× plus petit, 83M params) et le decoder (ConvNet léger). Le GPU ne touche à rien. + +**Le pipeline devient :** + + Thread 1 (NPU HMX) : Talker forward → 27ms → logits CB0 + hidden state + Thread 2 (CPU) : CP forward (15 codebooks) → ~75ms → CB1-CB15 + En parallèle avec le talker du token suivant + + Streaming decoder (CPU) : toutes les 4 tokens, decode un chunk audio + pendant que talker+CP continuent + + Timeline : + t=0.00s Talker NPU token 1 (27ms) + t=0.03s CP CPU token 1 (75ms) ← en parallèle avec talker token 2 + t=0.08s Talker NPU token 2 (27ms, fini pendant que CP travaille) + t=0.10s CP CPU token 1 fini, CP CPU commence token 2 + t=0.30s 4 tokens complets → decoder CPU lance chunk 1 + t=0.80s ▶ Premier son (~800ms) + GPU = 100% libre pendant TOUT le processus + +Par step en régime de croisière : `max(27ms talker NPU, 75ms CP CPU) = 75ms`. Total : `75ms × 50 = 3.75s + overhead ≈ 4.0s → RTF ~1.0`. + +**Le seul point à valider : le decoder sur CPU.** + +Le decoder fait 3.5s sur le NPU HTP pour ~50 tokens en bloc. Par chunk de 4 tokens sur CPU, il faudrait mesurer — c'est un ConvNet relativement léger (transposed convolutions + ConvNeXt blocks). Avec 8 cœurs Oryon à 4.32 GHz et NEON FP16, un chunk de 4 tokens devrait prendre **200-400ms** sur CPU. Si c'est sous 320ms (la durée audio du chunk), le streaming est fluide. Si c'est au-dessus, il y aura des micro-pauses mais l'audio commence quand même après ~800ms. + +Et le decoder est un ConvNet **sans argmax** — il tolère la quantification INT8 contrairement au talker/CP. En INT8 sur CPU avec les instructions I8MM du Oryon, le decoder serait encore 2× plus rapide. + +**Résumé de l'architecture :** + + ┌─────────────────────────────────────────────────────────┐ + │ NPU Hexagon HMX │ + │ (le gros du traitement IA) │ + │ │ + │ Talker 757M params, FP16 natif, 27ms/step │ + │ + LLM Qwen3 0.6B (ExecuTorch INT4, séquentiel) │ + │ + Whisper STT (QNN INT8, séquentiel) │ + └─────────────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────────────────┐ + │ CPU Oryon │ + │ (support léger) │ + │ │ + │ CP 83M params, FP16 NEON, ~75ms/step │ + │ Decoder ConvNet (INT8 NEON, streaming par chunks) │ + │ Silero VAD (1.8 Mo, trivial) │ + │ Sampling, embedding computation, IPC │ + └─────────────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────────────────┐ + │ GPU Adreno 830 │ + │ (100% LIBRE pour Unity) │ + │ │ + │ → Avatar 3D, lip-sync, animations empathiques │ + │ → 30-60 FPS rendu Vulkan sans interruption │ + └─────────────────────────────────────────────────────────┘ + +Ça s'aligne parfaitement avec ta vision. Le NPU fait le travail lourd (talker 757M + LLM + STT), le CPU fait le support (CP 83M + decoder + VAD), le GPU est entièrement dédié à l'expérience visuelle du patient. + +Tu veux qu'on lance le plan dans cet ordre ? + +--- + +You asked: +---------- + +### AVATAR\_3D\_RAPPORT.md + +md + +Voici la dernière pièce de l'intégration de Kazeia.... + +Apr 2 + +--- + +Claude Replied: +--------------- + +Orchestrated comprehensive analysis of Kazeia integration + +Orchestrated comprehensive analysis of Kazeia integration + +C'est un rapport remarquablement complet. La vision est claire, le protocole de capture est élégant (une seule séquence de 60 secondes qui sert le consentement, le voice cloning et la reconstruction 3D), et l'architecture respecte la contrainte GPU libre. + +**Quelques observations importantes :** + +**La timeline du §14 est la priorité à mettre à jour.** Le rapport montre encore le pipeline séquentiel (10s de génération → 3.5s decode → playback). Avec l'optimisation en cours (streaming + pipeline parallèle), cette timeline va se comprimer considérablement. L'avatar devrait commencer à exprimer l'émotion immédiatement quand le LLM génère le tag, puis commencer le lip-sync dès le premier chunk audio — pas après 10 secondes. + + Timeline actuelle (§14) : + t=0s LLM génère → t=0.1s émotion avatar + t=0.1s TTS génère → t=6s fini + t=6.5s Decode → t=10s audio prêt + t=10s ▶ Lip sync commence + → L'avatar est figé 10s entre l'émotion et la parole + + Timeline cible (avec streaming) : + t=0s LLM génère → t=0.1s émotion avatar + t=0.1s TTS génère premier chunk (4 tokens) + t=0.5s Decode chunk 1 sur CPU + t=0.8s ▶ Lip sync commence (pendant que TTS continue) + → L'avatar parle après 800ms, émotion et parole synchronisés + +Ce gap de 10 secondes entre l'expression émotionnelle et le début de la parole est problématique pour l'expérience thérapeutique — l'avatar fait une tête empathique pendant 10 secondes sans rien dire, c'est perturbant. Le streaming résout ça. + +**Le protocole de capture est ingénieux.** Utiliser la vidéo de consentement comme source pour le voice cloning est très malin — une seule interaction qui sert trois objectifs (légal, vocal, visuel). La phrase est bien choisie pour la diversité phonémique. Un point d'attention : les 6 secondes de la phrase de consentement donnent un x-vector viable mais pas optimal. Qwen3-TTS recommande 3-15 secondes de référence. Si la qualité du clonage est insuffisante avec 6 secondes, vous pourriez ajouter une petite phase "lecture d'un court texte" (10s) après le consentement — même UX guidée, texte différent, plus de diversité phonémique. + +**Le mode enfant comme premier livrable est la bonne stratégie.** L'ours en peluche valide tout le pipeline (Unity UaaL, lip-sync, bridge audio, émotions) sans la complexité de la reconstruction faciale. C'est le MVP de l'avatar. + +**Le budget GPU de <15% est conservateur — c'est bien.** Un mesh de 80K vertices avec 52 blendshapes à 90fps sur un Adreno 830 est trivial. Le shader skin PBR avec SSS approximé est le poste le plus coûteux mais reste léger sur une architecture tile-based comme l'Adreno. La marge de 85% absorbe les pics (transitions d'émotion, coarticulation lip-sync complexe). + +**Le risque uncanny valley est bien identifié.** Pour un chatbot thérapeutique, le semi-stylisé (style Pixar) est probablement plus sûr que le photoréaliste. Un avatar empathique légèrement stylisé est plus chaleureux qu'un visage réaliste qui tombe dans l'uncanny valley. La stratégie "photoréaliste d'abord, fallback semi-stylisé" est pragmatique, mais pour le contexte thérapeutique je recommanderais de tester le semi-stylisé en priorité — les patients en détresse émotionnelle sont plus sensibles à l'uncanny valley que l'utilisateur moyen. + +**L'intégration KazeiaService ↔ Unity est bien pensée.** Le pattern événementiel (onTtsAudioChunk, onEmotion, onSpeechStart, onSpeechEnd) est propre et découplé. Le SharedMemory pour l'audio est la bonne approche — pas de copie, Unity lit directement le buffer PCM. + +**Un point manquant : la gestion thermique.** Le NPU Hexagon (talker TTS) + CPU Oryon (CP + decoder) + GPU Adreno (avatar) tournant simultanément pendant une conversation de 30 minutes va chauffer le SoC. Le Snapdragon 8 Elite a un throttling thermique agressif. Il faudrait un profiling thermique avec tous les composants actifs en continu, et potentiellement un mode "éco" qui réduit le framerate avatar de 90fps à 60fps ou la résolution de rendu quand la température monte. + +C'est la dernière pièce du puzzle. L'architecture complète Kazeia est maintenant : + + Patient parle → VAD (CPU) → Whisper STT (NPU HTP) + → LLM Qwen3 (NPU HTP, séquentiel) → texte + tags émotion + → Avatar reçoit émotion (GPU, immédiat) + → TTS Talker (NPU HMX, 27ms/step) + CP (CPU, 75ms/step, parallèle) + → Decoder (CPU, streaming par chunks de 4 tokens) + → Audio chunk → AudioTrack + OVRLipSync → Avatar lip-sync (GPU, 90fps) + → Patient entend la voix clonée + voit l'avatar animé après ~800ms + +Tout est cohérent. Tu veux qu'on mette à jour la mémoire avec l'ensemble de ces documents et qu'on lance les prochaines étapes ? + +--- + +You asked: +---------- + +on a mis à jour le rapport avec ceci  ● CPU déjà à ~44°C au repos (sans avatar). C'est normal pour un Snapdragon 8 Elite mais ça confirme ton instinct. Oui, 60fps est le bon choix. Raisons : ┌───────────────────┬───────────────────────────────────┬─────────────────────────┬──────────────────────┐ │ │ 90fps │ 60fps │ 30fps │ ├───────────────────┼───────────────────────────────────┼─────────────────────────┼──────────────────────┤ │ Fluidité lip sync │ Excellent │ Très bon │ Saccadé en gros plan │ ├───────────────────┼───────────────────────────────────┼─────────────────────────┼──────────────────────┤ │ Charge GPU │ ~22% │ ~15% │ ~8% │ ├───────────────────┼───────────────────────────────────┼─────────────────────────┼──────────────────────┤ │ Thermique │ Risque throttle en session longue │ Stable │ Très froid │ ├───────────────────┼───────────────────────────────────┼─────────────────────────┼──────────────────────┤ │ Batterie │ Drain notable │ Raisonnable │ Minimal │ ├───────────────────┼───────────────────────────────────┼─────────────────────────┼──────────────────────┤ │ Écran 144Hz │ 90÷144 = judder │ 60÷144 = propre (2.4:1) │ 30÷144 = judder │ └───────────────────┴───────────────────────────────────┴─────────────────────────┴──────────────────────┘ 60fps est le sweet spot : - Lip sync reste fluide (16.7ms entre frames, imperceptible pour les lèvres) - Le GPU reste sous les 15% → pas de chauffe - Diviseur propre du 144Hz (via frame pacing) - Kazeia est une app de sessions longues (support émotionnel = 15-30 min) → la stabilité thermique est prioritaire + +Show more + +Apr 2 + +--- + +Claude Replied: +--------------- + +Validé le choix 60fps et mémorisa l'analyse thermique + +Validé le choix 60fps et mémorisa l'analyse thermique + +60fps est le choix parfait. L'analyse du diviseur 144Hz est pertinente — 60fps donne un frame pacing propre à 2.4:1 sans judder, contrairement au 90fps qui ne divise pas proprement le 144Hz. + +Et 44°C au repos confirme qu'il faut être conservateur. Avec le NPU (talker), le CPU (CP + decoder), et le GPU (avatar 60fps) tous actifs pendant 30 minutes, le thermal headroom est limité. Le 60fps à ~15% GPU laisse la marge nécessaire. + +Veux-tu que je mette à jour la mémoire avec l'ensemble des informations de cette session ? On a couvert énormément de terrain aujourd'hui. + +--- + +You asked: +---------- + +Le constat est que le démarrage de la partie Unity est très et que de plus nous avons un splashscreen au démarrage qu'il serait bon d'eviter, quelles sont les alternatives possibles ? + +Apr 3 + +--- + +Claude Replied: +--------------- + +Examiné les alternatives à Unity pour optimiser le démarrage + +Examiné les alternatives à Unity pour optimiser le démarrage + +Voici les alternatives concrètes, avec une recommandation claire. + +**Le problème Unity en deux mots :** le démarrage de Unity en tant que bibliothèque passe par trois phases — écran noir (3-4 secondes), splash screen Unity, puis encore un écran noir (2-3 secondes) — avant que la scène apparaisse [Unity](https://discussions.unity.com/t/how-do-i-reduce-unity-startup-time-as-a-third-party-library-in-an-android-application/256840). Et avec un abonnement Unity Personal, un splash screen par défaut s'affiche pendant le chargement et ne peut pas être modifié [Unity](https://docs.unity3d.com/2020.1/Documentation/Manual/AndroidMobileCustomizeSplashScreen.html). Pour le supprimer, il faut Unity Pro à 2200$/an. + +**Les alternatives :** + +**Option 1 — SceneView + Google Filament (ma recommandation)** + +SceneView est un SDK open-source, basé sur Filament et ARCore, l'alternative légère aux moteurs de jeu lourds pour la 3D dans les apps natives. Il ajoute seulement 3 Mo à l'APK et s'intègre nativement avec Jetpack Compose [SceneView](https://sceneview.github.io/). + +C'est la meilleure option pour Kazeia parce que Filament est le moteur de rendu PBR de Google, utilisé dans Android lui-même (visualiseur 3D natif, ARCore). Il supporte le PBR, le subsurface scattering approximé, les blendshapes/morph targets, et le chargement de glTF/GLB. L'intégration est native Kotlin — pas de bridge Java↔C#, pas d'Activity séparée, pas de lifecycle à gérer. SceneView fonctionne comme un simple View Android qu'on place dans un layout Compose ou XML. + + Avantages : + ├── Démarrage instantané (c'est un View natif, pas un moteur externe) + ├── Pas de splash screen + ├── 3 Mo au lieu de 40-80 Mo (Unity) + ├── Kotlin natif — même langage que l'app Kazeia + ├── PBR + morph targets/blendshapes supportés + ├── glTF/GLB import natif + ├── Pas de licence Pro nécessaire + ├── Lifecycle géré par Android (pas de conflit) + + Limitations : + ├── Pas d'éditeur visuel (tout en code) + ├── Animations procédurales à coder manuellement + ├── Pas d'OVRLipSync intégré (mais uLipSync ou custom) + ├── Communauté plus petite que Unity + +Pour le lip-sync, au lieu d'OVRLipSync (qui est un SDK Meta prévu pour Unity/Unreal), on utiliserait une approche custom légère : analyser l'audio PCM pour extraire les visèmes (énergie par bande de fréquence → mapping vers les 15 phonèmes de base → 52 blendshapes ARKit). C'est quelques centaines de lignes de Kotlin au lieu d'un SDK entier. + +**Option 2 — Godot Engine embarqué** + +Godot est open-source (MIT), pas de splash screen obligatoire, supporte le PBR et les blendshapes. Mais son intégration en tant que bibliothèque Android est moins mature que Unity UaaL. Le démarrage est plus rapide que Unity mais pas instantané (~1-2s). L'APK ajoute ~15-25 Mo. Le principal avantage c'est l'éditeur visuel pour créer les shaders et les animations — beaucoup plus accessible que tout coder en Filament. + +**Option 3 — Rester sur Unity mais contourner les problèmes** + +Pour le splash screen : Unity Pro (2200$/an) ou alors Unity Gaming Services qui offre parfois des licences éducatives pour les projets de recherche/master. Pour le démarrage lent : pré-charger Unity au lancement de l'app (dans `Application.onCreate`) plutôt qu'au moment d'afficher l'avatar. L'utilisateur ne voit pas le démarrage s'il se fait pendant l'écran d'accueil/login de Kazeia. + + Timeline contournement Unity : + t=0s App démarre → écran login Kazeia + t=0s En arrière-plan : Unity commence à charger + t=5-8s Utilisateur tape son mot de passe (pendant ce temps Unity charge) + t=8s Unity prêt en mémoire (invisible) + t=... Quand l'avatar est nécessaire → affichage instantané + +**Ma recommandation : SceneView + Filament.** + +Pour un projet de master avec une deadline, SceneView offre le meilleur ratio effort/résultat. Pas de licence, pas de splash screen, démarrage instantané, intégration Kotlin native, et le rendu Filament est magnifique pour un avatar de 80K vertices avec PBR. Le pipeline serait : + + Reconstruction DECA → mesh FLAME .glb avec 52 blendshapes + → chargé dans SceneView/Filament + → blendshapes pilotés par le lip-sync custom + → rendu PBR avec skin shader à 60fps + → tout dans un View Kotlin natif, pas d'Activity séparée + +Le seul effort supplémentaire par rapport à Unity c'est le lip-sync custom (pas d'OVRLipSync tout fait). Mais c'est un composant bien défini : audio PCM → FFT → énergie par bande → 15 visèmes → 52 blendshapes. C'est un bon sujet technique pour le mémoire. + +Tu veux qu'on explore SceneView + Filament plus en détail ? + +--- + +You asked: +---------- + +je te rappelle que le but sera d'avoir un clone d'une personne sur base de photo, ou un avatar 3d sur lequel on va devoir faire du lipsync (avec le TTS), mais également simuler des "micro geste" pour ne pas avoir un avatar figé + +Apr 3 + +--- + +Claude Replied: +--------------- + +Identifié une contrainte technique critique concernant Filament + +Identifié une contrainte technique critique concernant Filament + +Cette recherche révèle un **problème bloquant avec Filament/SceneView** que je n'avais pas vu initialement. + +Filament a une limitation [GitHub](https://github.com/google/filament/issues/4772) à 4 blend shapes actives simultanément. Pour un avatar facial avec 52 blendshapes ARKit, c'est insuffisant. Des discussions ouvertes demandent le support de plus de 4 ou 8 blend shapes, mais le problème persiste. Et les animations ne se blendent pas entre elles dans Filament — les matrices d'os s'écrasent mutuellement [GitHub](https://github.com/google/filament/discussions/4754), ce qui empêche de combiner lip-sync + émotions + micro-gestes simultanément. + +**C'est rédhibitoire pour Kazeia.** Un avatar crédible avec lip-sync + émotion + micro-gestes nécessite de piloter 15-20 blendshapes simultanément (bouche, yeux, sourcils, joues). Et il faut blender le lip-sync avec les expressions émotionnelles en continu. Filament ne peut pas faire ça. + +**Révision de ma recommandation — voici les vraies options :** + +**Option 1 — Godot Engine (ma nouvelle recommandation)** + +Godot est open-source MIT, pas de splash screen, pas de licence payante. Il supporte un nombre illimité de blendshapes, le blending d'animations (AnimationTree avec blend spaces), le PBR avec subsurface scattering, et l'intégration en tant que bibliothèque Android est supportée. Le démarrage est plus rapide que Unity (~1-2s vs 5-8s). L'APK ajoute ~15-20 Mo au lieu de 40-80 Mo pour Unity. + +Pour le lip-sync, Godot a des solutions natives : le nœud `AnimationTree` avec `BlendTree` permet de mixer lip-sync + émotion + idle en parallèle. Les blendshapes se pilotent directement via `MeshInstance3D.set_blend_shape_value()` à 60fps sans problème. Les micro-gestes (clignements, saccades oculaires, respiration, micro-expressions) sont des animations procédurales qui se blendent naturellement avec le reste. + + Godot pour Kazeia : + ├── 52 blendshapes ARKit : ✅ illimité + ├── Blending lip+émotion : ✅ AnimationTree BlendSpace + ├── Micro-gestes procéduraux : ✅ via script GDScript/C# + ├── PBR + skin shader : ✅ Spatial Material + SSS + ├── glTF/GLB import : ✅ natif + ├── Pas de splash screen : ✅ MIT, pas de contrainte + ├── Démarrage : ~1-2s (vs 5-8s Unity) + ├── Taille APK : ~15-20 Mo (vs 40-80 Mo Unity) + ├── Licence : MIT gratuit (vs Unity Pro 2200$/an) + ├── Langage : GDScript ou C# (familier) + └── Android intégration : supporté en tant que bibliothèque + +**Option 2 — Unity avec contournement du démarrage** + +Si l'écosystème Unity est préféré (plus de ressources, plus d'assets, OVRLipSync intégré), le contournement reste viable. Pré-charger Unity au lancement de l'app pendant que l'utilisateur se connecte. Le splash screen passe pendant l'écran de login Kazeia — l'utilisateur ne le voit pas. Pour la licence Pro (splash screen), vérifier si UTBM a un accord éducatif Unity. + + Unity contourné : + ├── 52 blendshapes : ✅ natif, excellent + ├── Blending lip+émotion : ✅ Animation Layers + ├── Micro-gestes procéduraux : ✅ scripts C# + ├── PBR + skin shader : ✅ URP/HDRP, meilleur que Godot + ├── OVRLipSync : ✅ SDK Meta gratuit + ├── Splash screen : ❌ sauf Pro (2200$/an) ou éducatif + ├── Démarrage : 5-8s (masqué par l'écran login) + ├── Taille APK : 40-80 Mo + ├── UaaL lifecycle : complexe mais documenté + └── Écosystème : le plus riche en assets/tutoriels + +**Option 3 — Vulkan/OpenGL ES custom (ambitieux mais léger)** + +Écrire un renderer minimal en Vulkan directement en Kotlin/C++. Pas de moteur de jeu, pas de splash screen, démarrage instantané, empreinte minimale. Le mesh FLAME de 80K vertices avec 52 blendshapes est géométriquement simple — c'est un seul objet, pas une scène de jeu complexe. Le blendshape se calcule en compute shader (somme pondérée de vertex deltas). Le PBR est un fragment shader standard. Les micro-gestes sont des fonctions procédurales en C++. + +C'est le plus performant et le plus léger, mais aussi le plus de travail d'ingénierie. Pour un projet de master avec deadline, c'est risqué sauf si vous avez de l'expérience Vulkan. + +**Ma recommandation finale : Godot.** + +Pour un avatar thérapeutique avec face cloning, 52 blendshapes, lip-sync TTS, micro-gestes, et émotions — Godot coche toutes les cases sans les contraintes de Unity (splash screen, licence, démarrage lent, lifecycle complexe) ni la limitation fatale de Filament (4 blendshapes max). Le rendu PBR de Godot 4 est suffisant pour un avatar semi-réaliste sur l'Adreno 830, et le AnimationTree gère nativement le blending multi-couche dont tu as besoin. + +Tu veux qu'on approfondisse Godot ou tu préfères explorer une autre piste ? + +--- + diff --git a/RAPPORT_TTS.md b/RAPPORT_TTS.md new file mode 100644 index 0000000..9b59fed --- /dev/null +++ b/RAPPORT_TTS.md @@ -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)* diff --git a/TTS_CALIBRATION_GUIDE.md b/TTS_CALIBRATION_GUIDE.md new file mode 100644 index 0000000..c2e3aa9 --- /dev/null +++ b/TTS_CALIBRATION_GUIDE.md @@ -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. diff --git a/TTS_GPU_GUIDE.md b/TTS_GPU_GUIDE.md new file mode 100644 index 0000000..6aa6249 --- /dev/null +++ b/TTS_GPU_GUIDE.md @@ -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. diff --git a/TTS_HEXAGON_NPU_GUIDE.md b/TTS_HEXAGON_NPU_GUIDE.md new file mode 100644 index 0000000..75b00b7 --- /dev/null +++ b/TTS_HEXAGON_NPU_GUIDE.md @@ -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 diff --git a/TTS_RAPPORT_COMPLET.md b/TTS_RAPPORT_COMPLET.md new file mode 100644 index 0000000..1bdecc4 --- /dev/null +++ b/TTS_RAPPORT_COMPLET.md @@ -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 diff --git a/TTS_REPORT.md b/TTS_REPORT.md new file mode 100644 index 0000000..f5ab43f --- /dev/null +++ b/TTS_REPORT.md @@ -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 diff --git a/kazeia-android/COMPILE_WHISPER_NPU.md b/kazeia-android/COMPILE_WHISPER_NPU.md new file mode 100644 index 0000000..d0f260d --- /dev/null +++ b/kazeia-android/COMPILE_WHISPER_NPU.md @@ -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 +``` + +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..export \ + --device "Snapdragon 8 Elite QRD" \ + --target-runtime precompiled_qnn_onnx \ + --precision float \ + --skip-profiling \ + --skip-inferencing \ + --output-dir /opt/Kazeia/models_qnn/ +``` + +### 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="/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 diff --git a/kazeia-android/RAPPORT_TTS_NPU.md b/kazeia-android/RAPPORT_TTS_NPU.md new file mode 100644 index 0000000..787a47a --- /dev/null +++ b/kazeia-android/RAPPORT_TTS_NPU.md @@ -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* diff --git a/kazeia-android/RAPPORT_TTS_QWEN3_TESTS.md b/kazeia-android/RAPPORT_TTS_QWEN3_TESTS.md new file mode 100644 index 0000000..2089d6a --- /dev/null +++ b/kazeia-android/RAPPORT_TTS_QWEN3_TESTS.md @@ -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)* diff --git a/kazeia-android/app/build.gradle.kts b/kazeia-android/app/build.gradle.kts new file mode 100644 index 0000000..e84388e --- /dev/null +++ b/kazeia-android/app/build.gradle.kts @@ -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")) +} diff --git a/kazeia-android/app/proguard-rules.pro b/kazeia-android/app/proguard-rules.pro new file mode 100644 index 0000000..11901ac --- /dev/null +++ b/kazeia-android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Kazeia ProGuard rules +-keep class com.kazeia.llm.GenieJni { *; } +-keep class ai.onnxruntime.** { *; } diff --git a/kazeia-android/app/src/main/AndroidManifest.xml b/kazeia-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4645859 --- /dev/null +++ b/kazeia-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kazeia-android/app/src/main/assets/voice_commands.json b/kazeia-android/app/src/main/assets/voice_commands.json new file mode 100644 index 0000000..b05b096 --- /dev/null +++ b/kazeia-android/app/src/main/assets/voice_commands.json @@ -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" + } + ] +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/KazeiaApplication.kt b/kazeia-android/app/src/main/java/com/kazeia/KazeiaApplication.kt new file mode 100644 index 0000000..a707cbb --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/KazeiaApplication.kt @@ -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) + } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/audio/AudioCaptureManager.kt b/kazeia-android/app/src/main/java/com/kazeia/audio/AudioCaptureManager.kt new file mode 100644 index 0000000..23b45d9 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/audio/AudioCaptureManager.kt @@ -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() + 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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/audio/AudioPlaybackManager.kt b/kazeia-android/app/src/main/java/com/kazeia/audio/AudioPlaybackManager.kt new file mode 100644 index 0000000..20aa71e --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/audio/AudioPlaybackManager.kt @@ -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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/conversation/ConversationManager.kt b/kazeia-android/app/src/main/java/com/kazeia/conversation/ConversationManager.kt new file mode 100644 index 0000000..039633a --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/conversation/ConversationManager.kt @@ -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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/conversation/EchoProcessor.kt b/kazeia-android/app/src/main/java/com/kazeia/conversation/EchoProcessor.kt new file mode 100644 index 0000000..4b91922 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/conversation/EchoProcessor.kt @@ -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() {} +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/conversation/LlmProcessor.kt b/kazeia-android/app/src/main/java/com/kazeia/conversation/LlmProcessor.kt new file mode 100644 index 0000000..4b53db6 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/conversation/LlmProcessor.kt @@ -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() + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/conversation/PromptBuilder.kt b/kazeia-android/app/src/main/java/com/kazeia/conversation/PromptBuilder.kt new file mode 100644 index 0000000..57d190a --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/conversation/PromptBuilder.kt @@ -0,0 +1,12 @@ +package com.kazeia.conversation + +import com.kazeia.core.ChatMessage + +class PromptBuilder { + + fun build( + message: String, + history: List, + maxHistoryTurns: Int = 3 + ): String = message +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/conversation/StoppingCriteria.kt b/kazeia-android/app/src/main/java/com/kazeia/conversation/StoppingCriteria.kt new file mode 100644 index 0000000..df12209 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/conversation/StoppingCriteria.kt @@ -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 } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor.kt b/kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor.kt new file mode 100644 index 0000000..02d7b91 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor.kt @@ -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, + 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() + + 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() + 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 = commands.toList() +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor2.kt b/kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor2.kt new file mode 100644 index 0000000..567925c --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/conversation/VoiceCommandProcessor2.kt @@ -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() {} +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/ConversationState.kt b/kazeia-android/app/src/main/java/com/kazeia/core/ConversationState.kt new file mode 100644 index 0000000..449a289 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/core/ConversationState.kt @@ -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 } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt new file mode 100644 index 0000000..19136fe --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/core/LlmEngine.kt @@ -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 +) diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/Pipeline.kt b/kazeia-android/app/src/main/java/com/kazeia/core/Pipeline.kt new file mode 100644 index 0000000..6487a3f --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/core/Pipeline.kt @@ -0,0 +1,62 @@ +package com.kazeia.core + +/** + * Conversation context shared across all processors. + */ +data class ConversationContext( + val history: List = emptyList(), + val metadata: MutableMap = 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 = 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 + + suspend fun processTextInput(text: String) + suspend fun processSpeechInput(audioData: ShortArray) + + fun startListening() + fun stopListening() +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/SttEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/core/SttEngine.kt new file mode 100644 index 0000000..4a53260 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/core/SttEngine.kt @@ -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 +) diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt new file mode 100644 index 0000000..a4d2f60 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt @@ -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 +) diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/VadEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/core/VadEngine.kt new file mode 100644 index 0000000..040c154 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/core/VadEngine.kt @@ -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() +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/llm/ExecuTorchLlmEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/llm/ExecuTorchLlmEngine.kt new file mode 100644 index 0000000..833c47c --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/llm/ExecuTorchLlmEngine.kt @@ -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 + val thinkEnd = text.indexOf("") + if (thinkEnd >= 0) { + text = text.substring(thinkEnd + "".length) + } else { + // No found — the think block consumed all tokens + // Try to find any text after the block that looks like a response + val thinkStart = text.indexOf("") + 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: 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("", "") + .replace("", "") + .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}") + "" + } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/llm/GenieJni.kt b/kazeia-android/app/src/main/java/com/kazeia/llm/GenieJni.kt new file mode 100644 index 0000000..d07d627 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/llm/GenieJni.kt @@ -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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/llm/GenieLlmEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/llm/GenieLlmEngine.kt new file mode 100644 index 0000000..be90f29 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/llm/GenieLlmEngine.kt @@ -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") + } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt new file mode 100644 index 0000000..30d112e --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt @@ -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() + private val context = ConversationContext() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs + + private val _pipelineState = MutableStateFlow(PipelineState.Idle) + val pipelineState: StateFlow = _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 = 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() } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt new file mode 100644 index 0000000..1522f50 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt @@ -0,0 +1,1070 @@ +package com.kazeia.service + +import android.Manifest +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.kazeia.KazeiaApplication +import com.kazeia.R +import com.kazeia.audio.AudioCaptureManager +import com.kazeia.audio.AudioPlaybackManager +import com.kazeia.conversation.ConversationManager +import com.kazeia.conversation.PromptBuilder +import com.kazeia.conversation.StoppingCriteria +import com.kazeia.core.* +import com.kazeia.llm.ExecuTorchLlmEngine +import com.kazeia.llm.GenieLlmEngine +import com.kazeia.stt.AndroidSttEngine +import com.kazeia.stt.WhisperHybridEngine +import com.kazeia.stt.WhisperSttEngine +import com.kazeia.tts.AndroidTtsEngine +import com.kazeia.tts.ChatterboxTtsEngine +import com.kazeia.ui.ChatActivity +import com.kazeia.vad.SileroVadEngine +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class KazeiaService : Service() { + + companion object { + private const val TAG = "KazeiaService" + private const val NOTIFICATION_ID = 1 + } + + private lateinit var llm: LlmEngine + private lateinit var stt: SttEngine + private lateinit var tts: TtsEngine + private lateinit var vad: VadEngine + + private lateinit var audioCapture: AudioCaptureManager + private lateinit var audioPlayback: AudioPlaybackManager + + private lateinit var conversationManager: ConversationManager + private lateinit var promptBuilder: PromptBuilder + private lateinit var stoppingCriteria: StoppingCriteria + private lateinit var voiceCommands: com.kazeia.conversation.VoiceCommandProcessor + + // New modular pipeline + val pipeline = KazeiaPipeline() + + private val _pipelineState = MutableStateFlow(PipelineState.Idle) + val pipelineState: StateFlow = _pipelineState + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs + + // AI workload tracking: which component is active (for monitoring) + data class AiWorkload( + val sttActive: Boolean = false, + val llmActive: Boolean = false, + val ttsActive: Boolean = false, + val lastSttMs: Long = 0, + val lastLlmMs: Long = 0, + val lastTtsMs: Long = 0 + ) + private val _aiWorkload = MutableStateFlow(AiWorkload()) + val aiWorkload: StateFlow = _aiWorkload + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening + + private val _debugMode = MutableStateFlow(false) + val debugMode: StateFlow = _debugMode + + // Loading progress for splash screen (0..100) + data class LoadingState(val progress: Int = 0, val step: String = "", val done: Boolean = false) + private val _loadingState = MutableStateFlow(LoadingState()) + val loadingState: StateFlow = _loadingState + + private 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" + } + + inner class KazeiaBinder : Binder() { + fun getService(): KazeiaService = this@KazeiaService + } + + private val binder = KazeiaBinder() + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Auto-pipeline trigger via: am startservice -n com.kazeia/.service.KazeiaService --ez run_pipeline true + if (intent?.getBooleanExtra("run_pipeline", false) == true) { + log("Auto-pipeline triggered via intent") + serviceScope.launch { + // Wait for TTS to be loaded + while (!::tts.isInitialized || tts !is com.kazeia.tts.Qwen3TtsEngine) { + kotlinx.coroutines.delay(1000) + } + processTextInput("pipeline") + } + } + intent?.getStringExtra("full_pipeline")?.let { embedsPath -> + val savePath = intent.getStringExtra("save_wav") ?: "/data/local/tmp/kazeia/tts_output.wav" + log("Full pipeline from pre-computed embeds: $embedsPath") + serviceScope.launch { + try { + val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine + if (qwenTts != null) { + val audio = qwenTts.generateFromEmbeds(embedsPath) + if (audio.isNotEmpty()) { + val f = java.io.File(savePath) + java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out -> + val dataSize = audio.size * 2 + out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize)) + out.writeBytes("WAVEfmt ") + out.writeInt(Integer.reverseBytes(16)) + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) + out.writeInt(Integer.reverseBytes(24000)) + out.writeInt(Integer.reverseBytes(48000)) + out.writeShort(java.lang.Short.reverseBytes(2).toInt()) + out.writeShort(java.lang.Short.reverseBytes(16).toInt()) + out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize)) + val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (s in audio) bb.putShort(s) + out.write(bb.array()) + } + log("Full pipeline WAV: $savePath (${audio.size} samples, ${audio.size/24000f}s)") + } else { + log("Full pipeline: empty audio") + } + } + } catch (e: Exception) { + log("Full pipeline error: ${e.message}") + e.printStackTrace() + } + } + } + intent?.getStringExtra("decode_codes")?.let { codesPath -> + val savePath = intent.getStringExtra("save_wav") ?: "/data/local/tmp/kazeia/tts_output.wav" + val realTokens = intent.getIntExtra("real_tokens", 58) + log("Decoding precomputed codes: $codesPath → $savePath ($realTokens tokens)") + serviceScope.launch { + try { + val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine + if (qwenTts != null) { + val audio = qwenTts.testWithPrecomputedCodes(codesPath, realTokens) + log("Decoded: ${audio.size} samples, ${audio.size * 1000L / 24000}ms") + // Write WAV + val f = java.io.File(savePath) + java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out -> + val dataSize = audio.size * 2 + out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize)) + out.writeBytes("WAVEfmt ") + out.writeInt(Integer.reverseBytes(16)) + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) + out.writeInt(Integer.reverseBytes(24000)) + out.writeInt(Integer.reverseBytes(48000)) + out.writeShort(java.lang.Short.reverseBytes(2).toInt()) + out.writeShort(java.lang.Short.reverseBytes(16).toInt()) + out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize)) + val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (s in audio) bb.putShort(s) + out.write(bb.array()) + } + log("WAV saved: $savePath (${audio.size} samples)") + } + } catch (e: Exception) { + log("Decode error: ${e.message}") + } + } + } + intent?.getStringExtra("tts_text")?.let { text -> + val savePath = intent.getStringExtra("save_wav") + if (savePath != null) { + log("TTS generate WAV: '$text' → $savePath") + serviceScope.launch { + try { + log("TTS synthesize starting for '$text'...") + val result = tts.synthesize(text, "fr") + log("TTS synthesize done: ${result.audioData.size} samples, ${result.sampleRate}Hz, ${result.durationMs}ms") + // Write WAV file + val audio = result.audioData + val f = java.io.File(savePath) + java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out -> + val dataSize = audio.size * 2 + val fileSize = 36 + dataSize + // RIFF header + out.writeBytes("RIFF") + out.writeInt(Integer.reverseBytes(fileSize)) + out.writeBytes("WAVE") + // fmt chunk + out.writeBytes("fmt ") + out.writeInt(Integer.reverseBytes(16)) + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) // PCM + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) // mono + out.writeInt(Integer.reverseBytes(result.sampleRate)) + out.writeInt(Integer.reverseBytes(result.sampleRate * 2)) // byte rate + out.writeShort(java.lang.Short.reverseBytes(2).toInt()) // block align + out.writeShort(java.lang.Short.reverseBytes(16).toInt()) // bits per sample + // data chunk + out.writeBytes("data") + out.writeInt(Integer.reverseBytes(dataSize)) + val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (s in audio) bb.putShort(s) + out.write(bb.array()) + } + log("WAV saved: ${f.absolutePath} (${audio.size} samples, ${audio.size * 1000L / result.sampleRate}ms)") + } catch (e: Exception) { + log("WAV error: ${e.message}") + } + } + } else { + log("TTS test via intent: '$text'") + processTextInput(text) + } + } + return START_STICKY + } + + override fun onCreate() { + super.onCreate() + val hasMicPermission = ContextCompat.checkSelfPermission( + this, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + + val fgsType = if (hasMicPermission) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, createNotification(), fgsType) + } else { + startForeground(NOTIFICATION_ID, createNotification()) + } + initializeComponents() + } + + private fun initializeComponents() { + serviceScope.launch { + try { + _pipelineState.value = PipelineState.Idle + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "Initialisation des modèles…" + )) + + // Initialize engines + val modelsDir = KazeiaApplication.MODELS_DIR + _loadingState.value = LoadingState(5, "VAD…") + vad = SileroVadEngine() + vad.load(this@KazeiaService) + log("VAD loaded") + _loadingState.value = LoadingState(10, "VAD OK") + + val nativeLibDir = applicationInfo.nativeLibraryDir + + // TTS: try Qwen3-TTS (NPU Hexagon), fallback to Android TTS + _loadingState.value = LoadingState(15, "TTS Qwen3…") + try { + val qwenTts = com.kazeia.tts.Qwen3TtsEngine(nativeLibDir) { msg -> log("[TTS] $msg") } + qwenTts.load("$modelsDir/qwen3-tts-npu") + if (qwenTts.isLoaded()) { + tts = qwenTts + log("TTS: Qwen3-TTS loaded (Hexagon NPU)") + } else { + tts = AndroidTtsEngine(this@KazeiaService) + tts.load(voiceId = "kazeia_fr") + log("TTS: Qwen3 failed, using Android TTS") + } + } catch (e: Exception) { + log("TTS: Qwen3 error: ${e.message}, using Android TTS") + tts = AndroidTtsEngine(this@KazeiaService) + tts.load(voiceId = "kazeia_fr") + } + _loadingState.value = LoadingState(20, "TTS OK") + + // STT: try NPU first, fallback to CPU + _loadingState.value = LoadingState(25, "STT Whisper…") + var sttLoaded = false + + // Try 1: Whisper NPU (Qualcomm ONNX QNN EP) + try { + val npuStt = WhisperHybridEngine(nativeLibDir) { msg -> log(msg) } + npuStt.load("$modelsDir/whisper-small-sm8750") + if (npuStt.isLoaded()) { + stt = npuStt + sttLoaded = true + log("Using Whisper ONNX") + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "STT: Whisper ONNX loaded" + )) + } + } catch (e: Exception) { + log("WARN: Whisper NPU failed: ${e.message}") + } + + // Try 2: whisper.cpp CPU + if (!sttLoaded) { + try { + val cpuStt = WhisperSttEngine() + cpuStt.load("$modelsDir/whisper-base") + if (cpuStt.isLoaded()) { + stt = cpuStt + sttLoaded = true + log("Using whisper.cpp (CPU)") + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "STT: Whisper-Base (CPU)" + )) + } + } catch (e: Exception) { + log("WARN: whisper.cpp failed: ${e.message}") + } + } + + if (!sttLoaded) { + stt = AndroidSttEngine(this@KazeiaService) + stt.load() + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "STT: Android natif" + )) + } + + // LLM: disabled for debugging — echo mode + _loadingState.value = LoadingState(50, "LLM (echo mode)…") + llm = ExecuTorchLlmEngine { msg -> log(msg) } + log("LLM: disabled for debug, echo mode active") + + _loadingState.value = LoadingState(80, "Audio…") + // Audio + audioPlayback = AudioPlaybackManager() + audioCapture = AudioCaptureManager() + + // Conversation logic + promptBuilder = PromptBuilder() + stoppingCriteria = StoppingCriteria() + conversationManager = ConversationManager() + voiceCommands = com.kazeia.conversation.VoiceCommandProcessor(this@KazeiaService) + log("Voice commands loaded: ${voiceCommands.getCommands().size} commands") + + // Set default voice for Chatterbox + val defaultVoice = "${KazeiaApplication.MODELS_DIR}/../voix/damien.wav" + setVoice(defaultVoice) + + addMessage(ChatMessage( + role = ChatMessage.Role.KAZEIA, + text = "Bonjour, je suis Kazeia. Mode perroquet actif." + )) + _pipelineState.value = PipelineState.Idle + + // Setup modular pipeline + pipeline.setStt(stt) + pipeline.setTts(tts) + + // Add processors in order: voice commands first, then LLM + pipeline.addProcessor(com.kazeia.conversation.VoiceCommandProcessor2(this@KazeiaService)) + if (llm.isLoaded()) { + pipeline.addProcessor(com.kazeia.conversation.LlmProcessor( + llm, "${KazeiaApplication.MODELS_DIR}/qwen3-4b" + )) + } else { + pipeline.addProcessor(com.kazeia.conversation.EchoProcessor()) + } + + _loadingState.value = LoadingState(100, "Prêt", done = true) + log("All components initialized") + log("Pipeline: STT(${stt::class.simpleName}) → [${pipeline.getProcessors().joinToString(" → ") { it.name }}] → TTS(${tts::class.simpleName})") + } catch (e: Exception) { + log("ERROR: Initialization error: ${e.message}") + _loadingState.value = LoadingState(100, "Erreur: ${e.message}", done = true) + _pipelineState.value = PipelineState.Error(e.message ?: "Erreur d'initialisation") + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "Erreur: ${e.message}. Les modèles natifs ne sont pas encore chargés — le mode texte avec Android TTS est actif." + )) + _pipelineState.value = PipelineState.Idle + } + } + } + + fun setVoice(voicePath: String) { + val chatterbox = tts as? ChatterboxTtsEngine + if (chatterbox != null) { + chatterbox.setVoice(voicePath) + log("Voice set to: $voicePath") + } + } + + fun interruptTts() { + if (_pipelineState.value is PipelineState.Speaking) { + log("TTS interrupted by user") + tts.stop() + _pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle + } + } + + @Volatile private var pttRecording = false + + fun toggleListening() { + // If TTS is speaking, interrupt it + if (_pipelineState.value is PipelineState.Speaking) { + log("Interrupting TTS") + tts.stop() + _pipelineState.value = PipelineState.Idle + } + + if (_isListening.value) { + // Stop recording + log("PTT: Stop requested") + _isListening.value = false + } else if (!pttRecording) { + // Start recording (only if not already initializing) + startListening() + } else { + log("PTT: Already initializing, ignoring toggle") + } + } + + private fun startListening() { + log("startListening: stt=${stt::class.simpleName}, vad=${vad.isLoaded()}") + _isListening.value = true + _pipelineState.value = PipelineState.Listening + + startContinuousListening() + } + + /** + * Simple push-to-talk: records until user presses stop. + * No VAD — just raw recording → Whisper STT. + */ + @android.annotation.SuppressLint("MissingPermission") + private fun startPushToTalkRecording() { + serviceScope.launch(Dispatchers.IO) { + pttRecording = true + val sampleRate = 16000 + try { + val bufferSize = maxOf( + android.media.AudioRecord.getMinBufferSize( + sampleRate, + android.media.AudioFormat.CHANNEL_IN_MONO, + android.media.AudioFormat.ENCODING_PCM_16BIT + ), + sampleRate * 2 + ) + log("PTT: Creating AudioRecord, bufferSize=$bufferSize") + + // Try VOICE_RECOGNITION first, then MIC + var recorder: android.media.AudioRecord? = null + for (source in listOf( + android.media.MediaRecorder.AudioSource.VOICE_RECOGNITION, + android.media.MediaRecorder.AudioSource.MIC + )) { + val name = if (source == 6) "VOICE_RECOGNITION" else "MIC" + try { + val r = android.media.AudioRecord( + source, sampleRate, + android.media.AudioFormat.CHANNEL_IN_MONO, + android.media.AudioFormat.ENCODING_PCM_16BIT, + bufferSize + ) + if (r.state == android.media.AudioRecord.STATE_INITIALIZED) { + r.startRecording() + recorder = r + log("PTT: Recording with $name") + break + } else { + log("PTT: $name failed (state=${r.state})") + r.release() + } + } catch (e: Exception) { + log("PTT: $name exception: ${e.message}") + } + } + + if (recorder == null) { + log("PTT: ERROR — no audio source works!") + _pipelineState.value = PipelineState.Error("Micro non disponible") + _isListening.value = false + return@launch + } + + // Record until user toggles off + val allSamples = mutableListOf() + val chunk = ShortArray(1600) // 100ms chunks + log("PTT: Recording... (press mic again to stop)") + + while (_isListening.value) { + val read = recorder.read(chunk, 0, chunk.size) + if (read > 0) { + allSamples.add(chunk.copyOf(read)) + } + } + + recorder.stop() + recorder.release() + + // Combine all samples + val totalSamples = allSamples.sumOf { it.size } + val audio = ShortArray(totalSamples) + var offset = 0 + for (s in allSamples) { + s.copyInto(audio, offset) + offset += s.size + } + + val durationSec = totalSamples.toFloat() / sampleRate + log("PTT: Recorded ${durationSec}s ($totalSamples samples)") + + if (totalSamples < sampleRate / 2) { + // Less than 0.5s — too short + log("PTT: Too short, ignoring") + _pipelineState.value = PipelineState.Idle + return@launch + } + + // Send to Whisper STT + _pipelineState.value = PipelineState.Transcribing + log("PTT: Sending to Whisper...") + val result = stt.transcribe(audio) + log("PTT: Transcribed: '${result.text}' (conf=${result.confidence}, lang=${result.language}, ${result.durationMs}ms)") + + if (result.text.isNotBlank()) { + _pipelineState.value = PipelineState.Transcribed(result.text) + addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = result.text)) + // Process through pipeline + processTextInput(result.text) + } else { + log("PTT: Empty transcription") + _pipelineState.value = PipelineState.Idle + } + + } catch (e: Exception) { + log("PTT ERROR: ${e.message}") + _pipelineState.value = PipelineState.Error("Erreur micro: ${e.message}") + _isListening.value = false + } finally { + pttRecording = false + } + } + } + + /** + * Continuous listening with energy-based VAD (DISABLED — using push-to-talk). + * Detects speech segments and sends each one to Whisper. + * Runs until user presses stop (isListening = false). + */ + @android.annotation.SuppressLint("MissingPermission") + private fun startContinuousListening() { + serviceScope.launch(Dispatchers.IO) { + val sampleRate = 16000 + val frameSize = 1600 // 100ms frames for VAD + val silenceThreshold = 150 // RMS threshold for speech detection + val speechMinFrames = 3 // min 300ms of speech to trigger + val silenceEndFrames = 8 // 800ms of silence to end segment + val maxSegmentSamples = sampleRate * 30 // max 30s per segment + + try { + val bufferSize = maxOf( + android.media.AudioRecord.getMinBufferSize( + sampleRate, + android.media.AudioFormat.CHANNEL_IN_MONO, + android.media.AudioFormat.ENCODING_PCM_16BIT + ), + sampleRate * 2 + ) + + val minBuf = android.media.AudioRecord.getMinBufferSize(sampleRate, android.media.AudioFormat.CHANNEL_IN_MONO, android.media.AudioFormat.ENCODING_PCM_16BIT) + log("AudioRecord: minBuf=$minBuf, bufferSize=$bufferSize") + + // Try VOICE_RECOGNITION first, then MIC + var recorder: android.media.AudioRecord? = null + for (source in listOf(android.media.MediaRecorder.AudioSource.VOICE_RECOGNITION, android.media.MediaRecorder.AudioSource.MIC)) { + val sourceName = if (source == android.media.MediaRecorder.AudioSource.VOICE_RECOGNITION) "VOICE_RECOGNITION" else "MIC" + try { + val r = android.media.AudioRecord(source, sampleRate, android.media.AudioFormat.CHANNEL_IN_MONO, android.media.AudioFormat.ENCODING_PCM_16BIT, bufferSize) + if (r.state == android.media.AudioRecord.STATE_INITIALIZED) { + r.startRecording() + recorder = r + log("AudioRecord OK: source=$sourceName") + break + } else { + log("AudioRecord FAILED: source=$sourceName state=${r.state}") + r.release() + } + } catch (e: Exception) { + log("AudioRecord exception ($sourceName): ${e.message}") + } + } + + if (recorder == null) { + log("ERROR: No audio source available!") + _pipelineState.value = PipelineState.Error("Micro non disponible") + return@launch + } + log("Continuous listening started (VAD energy, threshold=$silenceThreshold)") + + val frame = ShortArray(frameSize) + val speechBuffer = mutableListOf() + var speechFrameCount = 0 + var silenceFrameCount = 0 + var isSpeechActive = false + var frameCount = 0 + var speechStartTime = 0L + val streamingIntervalSamples = sampleRate * 2 // send to Whisper every 2s of speech + var lastStreamedSamples = 0 + var streamingResults = mutableListOf() + + var wasMuted = false + + while (_isListening.value) { + val read = recorder.read(frame, 0, frameSize) + if (read != frameSize) continue + + val now = System.currentTimeMillis() + + val isTtsSpeaking = _pipelineState.value is PipelineState.Speaking + val isTranscribing = _pipelineState.value is PipelineState.Transcribing + + // Mute during TTS (mic button to interrupt) + if (isTtsSpeaking) { + if (isSpeechActive) { + speechBuffer.clear(); speechFrameCount = 0; silenceFrameCount = 0 + isSpeechActive = false; lastStreamedSamples = 0; streamingResults.clear() + } + continue + } + + // Mute during transcription + if (isTranscribing) continue + + if (wasMuted) { log("Mic unmuted"); wasMuted = false } + + // Compute RMS energy + var sumSq = 0L + for (s in frame) sumSq += s.toLong() * s.toLong() + val rms = Math.sqrt(sumSq.toDouble() / frameSize).toInt() + + // Log RMS every second for calibration + if (frameCount % 10 == 0) { + Log.d(TAG, "VAD RMS=$rms (threshold=$silenceThreshold)") + } + frameCount++ + + val isSpeech = rms > silenceThreshold + + if (isSpeech) { + silenceFrameCount = 0 + speechFrameCount++ + speechBuffer.add(frame.copyOf()) + + if (speechFrameCount >= speechMinFrames && !isSpeechActive) { + isSpeechActive = true + speechStartTime = System.currentTimeMillis() + lastStreamedSamples = 0 + streamingResults.clear() + _pipelineState.value = PipelineState.SpeechDetected + log("Speech detected (RMS=$rms)") + } + + // Streaming: send chunk to Whisper every 2s while still speaking + val currentSamples = speechBuffer.sumOf { it.size } + if (isSpeechActive && currentSamples - lastStreamedSamples >= streamingIntervalSamples) { + lastStreamedSamples = currentSamples + val chunkAudio = ShortArray(currentSamples) + var off = 0 + for (chunk in speechBuffer) { chunk.copyInto(chunkAudio, off); off += chunk.size } + val chunkMs = currentSamples * 1000L / sampleRate + log("Streaming chunk: ${chunkMs}ms → Whisper") + val audioSnapshot = chunkAudio.copyOf() + serviceScope.launch(Dispatchers.IO) { + val t = System.currentTimeMillis() + val result = stt.transcribe(audioSnapshot, language = "fr") + val elapsed = System.currentTimeMillis() - t + if (result.text.isNotBlank()) { + streamingResults.clear() + streamingResults.add(result.text.trim()) + log("Streaming result (${elapsed}ms): \"${result.text.trim()}\"") + // Show intermediate result + _pipelineState.value = PipelineState.Transcribed(result.text.trim()) + } + } + } + } else { + if (isSpeechActive) { + silenceFrameCount++ + speechBuffer.add(frame.copyOf()) // keep transition silence + + if (silenceFrameCount >= silenceEndFrames) { + // End of speech — send to Whisper + val fullAudio = ShortArray(speechBuffer.sumOf { it.size }) + var offset = 0 + for (chunk in speechBuffer) { + chunk.copyInto(fullAudio, offset) + offset += chunk.size + } + + val speechDuration = System.currentTimeMillis() - speechStartTime + val audioDurationMs = fullAudio.size * 1000L / sampleRate + val unprocessedMs = (fullAudio.size - lastStreamedSamples) * 1000L / sampleRate + log("Silence detected → ${audioDurationMs}ms audio, speech ${speechDuration}ms, unprocessed ${unprocessedMs}ms") + + val audioToTranscribe = fullAudio + val segmentStartTime = speechStartTime + val hasStreamingResult = streamingResults.isNotEmpty() + + serviceScope.launch(Dispatchers.IO) { + if (hasStreamingResult && unprocessedMs < 1500) { + // Streaming already covered most of the audio — use it + val text = streamingResults.joinToString(" ") + val totalLatency = System.currentTimeMillis() - segmentStartTime + log("Using streaming result: \"$text\" | Total=${totalLatency}ms (saved full re-transcription)") + _pipelineState.value = PipelineState.Transcribed(text) + if (handleVoiceCommand(text)) return@launch + addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = text)) + processLlmResponse(text) + } else { + // Need full transcription (new audio since last stream) + _pipelineState.value = PipelineState.Transcribing + val sttStart = System.currentTimeMillis() + processSpeechInput(audioToTranscribe) + val sttDuration = System.currentTimeMillis() - sttStart + val totalLatency = System.currentTimeMillis() - segmentStartTime + log("Timing: STT=${sttDuration}ms | Total(detect→text)=${totalLatency}ms") + } + if (_isListening.value) { + _pipelineState.value = PipelineState.Listening + } + } + + // Reset for next segment + speechBuffer.clear() + speechFrameCount = 0 + silenceFrameCount = 0 + isSpeechActive = false + } + } else { + // No speech, reset + speechBuffer.clear() + speechFrameCount = 0 + } + } + + // Safety: don't accumulate too much + if (speechBuffer.sumOf { it.size } > maxSegmentSamples) { + log("WARN: " +"Max segment length reached, forcing transcription") + val fullAudio = ShortArray(speechBuffer.sumOf { it.size }) + var off = 0 + for (chunk in speechBuffer) { chunk.copyInto(fullAudio, off); off += chunk.size } + serviceScope.launch(Dispatchers.IO) { + _pipelineState.value = PipelineState.Transcribing + processSpeechInput(fullAudio) + if (_isListening.value) _pipelineState.value = PipelineState.Listening + } + speechBuffer.clear() + speechFrameCount = 0 + silenceFrameCount = 0 + isSpeechActive = false + } + } + + recorder.stop() + recorder.release() + log("Continuous listening stopped") + + } catch (e: Exception) { + log("ERROR: Continuous listening error: ${e.message}") + } + + _isListening.value = false + _pipelineState.value = PipelineState.Idle + } + } + + /** + * Handle voice commands. Returns true if a command was matched (skip normal processing). + */ + private fun handleVoiceCommand(text: String): Boolean { + val match = voiceCommands.match(text) ?: return false + + log("Voice command: ${match.command.action} (param=${match.param})") + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "[Commande] ${match.command.description}" + )) + + when (match.command.action) { + "STOP_LISTENING" -> { + stopListening() + } + "CLEAR_CHAT" -> { + _messages.value = emptyList() + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "Conversation effacée." + )) + } + "CHANGE_VOICE" -> { + val voiceName = match.param?.trim()?.lowercase() + if (voiceName != null) { + val voiceFile = listOf("damien", "elodie", "jerome", "richard", "amir", "didier", "sid", "zelda") + .firstOrNull { voiceName.contains(it) } + if (voiceFile != null) { + val path = "${KazeiaApplication.MODELS_DIR}/../voix/$voiceFile.wav" + setVoice(path) + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "Voix changée: $voiceFile" + )) + } + } + } + "REPEAT" -> { + val lastKazeia = _messages.value.lastOrNull { it.role == ChatMessage.Role.KAZEIA } + if (lastKazeia != null) { + serviceScope.launch { + _pipelineState.value = PipelineState.Speaking + tts.synthesizeAndPlay(lastKazeia.text, "fr", + onComplete = { + _pipelineState.value = if (_isListening.value) + PipelineState.Listening else PipelineState.Idle + } + ) + } + } + } + "DEBUG_START" -> { + _debugMode.value = true + } + "DEBUG_STOP" -> { + _debugMode.value = false + } + "STATUS" -> { + val status = buildString { + append("STT: ${stt::class.simpleName}") + append(" | TTS: ${tts::class.simpleName}") + append(" | LLM: ${if (llm.isLoaded()) "actif" else "echo"}") + append(" | Ecoute: ${_isListening.value}") + } + addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = status)) + } + "LIST_COMMANDS" -> { + val cmdList = voiceCommands.getCommands().joinToString("\n") { cmd -> + "• ${cmd.triggers.first()} → ${cmd.description}" + } + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "Commandes disponibles :\n$cmdList" + )) + } + } + return true + } + + private fun stopListening() { + _isListening.value = false + val androidStt = stt as? AndroidSttEngine + androidStt?.stopListening() + audioCapture.stop() + _pipelineState.value = PipelineState.Idle + } + + private suspend fun processSpeechInput(audioData: ShortArray) { + _pipelineState.value = PipelineState.Transcribing + val audioMs = audioData.size * 1000L / 16000 + _aiWorkload.value = _aiWorkload.value.copy(sttActive = true) + log(">>> STT START: ${audioMs}ms audio, ${audioData.size} samples, stt=${stt::class.simpleName}") + + val t0 = System.currentTimeMillis() + val transcription = stt.transcribe(audioData, language = "fr") + val whisperMs = System.currentTimeMillis() - t0 + + if (transcription.text.isBlank()) { + log("Whisper: (vide) ${whisperMs}ms") + _pipelineState.value = PipelineState.Listening + return + } + + _aiWorkload.value = _aiWorkload.value.copy(sttActive = false, lastSttMs = whisperMs) + log("Whisper: \"${transcription.text}\" en ${whisperMs}ms (RTF=${"%.2f".format(whisperMs.toFloat() / audioMs)})") + _pipelineState.value = PipelineState.Transcribed(transcription.text) + + // Check voice commands before processing + if (handleVoiceCommand(transcription.text)) return + + addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = transcription.text)) + processLlmResponse(transcription.text) + } + + fun processTextInput(text: String) { + log("processTextInput: '$text'") + serviceScope.launch { + // Special test commands + if (text.trim().lowercase().let { it.startsWith("pipeline") || it.startsWith("!pipeline") || it.startsWith("\\!pipeline") || it == "go" }) { + val embedsPath = "/data/local/tmp/kazeia/models/qwen3-tts-npu/full_pipeline_embeds.bin" + val wavPath = "/data/local/tmp/kazeia/tts_output.wav" + addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Running full pipeline...")) + try { + val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine + val audio = qwenTts?.generateFromEmbeds(embedsPath) ?: ShortArray(0) + if (audio.isNotEmpty()) { + val f = java.io.File(wavPath) + java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out -> + val dataSize = audio.size * 2 + out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize)) + out.writeBytes("WAVEfmt "); out.writeInt(Integer.reverseBytes(16)) + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) + out.writeShort(java.lang.Short.reverseBytes(1).toInt()) + out.writeInt(Integer.reverseBytes(24000)); out.writeInt(Integer.reverseBytes(48000)) + out.writeShort(java.lang.Short.reverseBytes(2).toInt()) + out.writeShort(java.lang.Short.reverseBytes(16).toInt()) + out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize)) + val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (s in audio) bb.putShort(s) + out.write(bb.array()) + } + addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "WAV: $wavPath (${audio.size/24000f}s)")) + } else { + addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Pipeline: empty audio")) + } + } catch (e: Exception) { + addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Pipeline error: ${e.message}")) + } + return@launch + } + + // Check voice commands before processing + if (handleVoiceCommand(text)) return@launch + + addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = text)) + processLlmResponse(text) + } + } + + private suspend fun processLlmResponse(patientMessage: String) { + _pipelineState.value = PipelineState.Thinking + conversationManager.onNewTurn() + + // If LLM not loaded, use echo mode with TTS + if (!llm.isLoaded()) { + log("Echo mode: '$patientMessage' → TTS (${tts::class.simpleName})") + val echoResponse = patientMessage + addMessage(ChatMessage(role = ChatMessage.Role.KAZEIA, text = echoResponse)) + _pipelineState.value = PipelineState.Speaking + tts.synthesizeAndPlay( + text = echoResponse, + language = "fr", + onComplete = { + _pipelineState.value = if (_isListening.value) + PipelineState.Listening else PipelineState.Idle + } + ) + _pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle + return + } + + val prompt = promptBuilder.build( + message = patientMessage, + history = _messages.value + ) + + try { + stoppingCriteria.reset() + val responseBuilder = StringBuilder() + _aiWorkload.value = _aiWorkload.value.copy(llmActive = true) + + val result = llm.generate( + prompt = prompt, + params = SamplingParams( + maxNewTokens = 450, + temperature = conversationManager.currentTemperature() + ), + onToken = { token -> + responseBuilder.append(token) + _pipelineState.value = PipelineState.TokenGenerated( + token = token, + fullText = responseBuilder.toString() + ) + !stoppingCriteria.shouldStop(responseBuilder.toString()) + } + ) + + _aiWorkload.value = _aiWorkload.value.copy(llmActive = false) + val responseText = result.text.trim() + log("LLM prompt: '$prompt'") + log("LLM raw response: '${result.text}'") + log("LLM clean response: '$responseText'") + log("LLM stats: ${result.tokenCount} tokens in ${result.timeMs}ms (${result.tokensPerSecond} tok/s)") + + if (responseText.isNotEmpty()) { + addMessage(ChatMessage(role = ChatMessage.Role.KAZEIA, text = responseText)) + + _pipelineState.value = PipelineState.Speaking + tts.synthesizeAndPlay( + text = responseText, + language = "fr", + onComplete = { + _pipelineState.value = if (_isListening.value) + PipelineState.Listening else PipelineState.Idle + } + ) + } + + _pipelineState.value = if (_isListening.value) + PipelineState.Listening else PipelineState.Idle + + } catch (e: Exception) { + _aiWorkload.value = _aiWorkload.value.copy(llmActive = false) + log("ERROR: LLM generation error: ${e.message}") + _pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM") + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "Erreur lors de la génération: ${e.message}" + )) + _pipelineState.value = PipelineState.Idle + } + } + + private fun addMessage(message: ChatMessage) { + _messages.value = _messages.value + message + } + + private fun createNotification(): Notification { + val intent = Intent(this, ChatActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, KazeiaApplication.CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(getString(R.string.notification_text)) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + if (_isListening.value) audioCapture.stop() + try { llm.release() } catch (_: Exception) {} + try { stt.release() } catch (_: Exception) {} + try { tts.release() } catch (_: Exception) {} + try { vad.release() } catch (_: Exception) {} + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/stt/AndroidSttEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/stt/AndroidSttEngine.kt new file mode 100644 index 0000000..6b86a9b --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/stt/AndroidSttEngine.kt @@ -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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/stt/MelExtractor.kt b/kazeia-android/app/src/main/java/com/kazeia/stt/MelExtractor.kt new file mode 100644 index 0000000..bafb2d0 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/stt/MelExtractor.kt @@ -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? +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt new file mode 100644 index 0000000..8e9156d --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt @@ -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 = 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() + 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 { + // --- 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>() + 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>): List { + val tokens = mutableListOf() + 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() + 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 { + 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> + 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 { + val tokens = mutableListOf() + 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>)[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 by lazy { + val map = mutableMapOf() + 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): String { + if (vocab.isEmpty()) return tokens.joinToString(" ") { "#$it" } + + val bytes = mutableListOf() + 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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperJni.kt b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperJni.kt new file mode 100644 index 0000000..a8b7f10 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperJni.kt @@ -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) +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperLiteRtEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperLiteRtEngine.kt new file mode 100644 index 0000000..8f9b9cc --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperLiteRtEngine.kt @@ -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 = 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() + 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() + 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): 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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperNpuSttEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperNpuSttEngine.kt new file mode 100644 index 0000000..e5d0c1e --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperNpuSttEngine.kt @@ -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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperSttEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperSttEngine.kt new file mode 100644 index 0000000..3dbc560 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/stt/WhisperSttEngine.kt @@ -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 + } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/tts/AndroidTtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/tts/AndroidTtsEngine.kt new file mode 100644 index 0000000..54e70df --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/tts/AndroidTtsEngine.kt @@ -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 + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/tts/ChatterboxTtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/tts/ChatterboxTtsEngine.kt new file mode 100644 index 0000000..4c02d56 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/tts/ChatterboxTtsEngine.kt @@ -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>) + 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() + 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() + 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>)[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>)[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)[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 + + init { + val json = JSONObject(file.readText()) + val model = json.getJSONObject("model") + val vocabObj = model.getJSONObject("vocab") + val map = mutableMapOf() + 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() + 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() + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt new file mode 100644 index 0000000..7a7a053 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt @@ -0,0 +1,2566 @@ +package com.kazeia.tts + +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OnnxJavaType +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 java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.nio.LongBuffer +import java.nio.IntBuffer +import kotlin.coroutines.resume + +/** + * Qwen3-TTS Engine — Full NPU pipeline for voice cloning TTS. + * + * Pipeline: + * 1. Talker NPU (ORT KV-cache decoder) → codec tokens (codebook 0) + * 2. Code Predictor NPU (QNN ONNX) → 16 codebooks + * 3. VQ decode (Kotlin) → quantized [1, 512, 60] + * 4. pre_conv NPU → preprocessor NPU → ConvNet decoder NPU → audio WAV + * + * All heavy computation on NPU. VQ decode is trivial CPU (codebook lookup). + */ +class Qwen3TtsEngine( + private val nativeLibDir: String, + private val onLog: ((String) -> Unit)? = null +) : TtsEngine { + + companion object { + private const val TAG = "Qwen3TTS" + private const val SR = 24000 + private const val SEQ_LEN = 60 // Fixed NPU chunk size for decoder + private const val CHUNK_OVERLAP = 10 + private const val EFFECTIVE_CHUNK = SEQ_LEN - CHUNK_OVERLAP + private const val SAMPLES_PER_TOKEN = 1920 + private const val CODEC_OFFSET = 1050 + private const val NUM_CODEBOOKS = 16 + private const val CODEBOOK_SIZE = 2048 + private const val CODEBOOK_DIM = 256 + private const val HIDDEN_DIM = 512 + + // Talker KV-cache model constants + private const val TALKER_DIM = 1024 + private const val TALKER_VOCAB = 3072 // codec vocabulary size + private const val TALKER_LAYERS = 28 + private const val TALKER_HEADS = 8 + private const val TALKER_HEAD_DIM = 128 + private const val KV_LEN = 199 + private const val MAX_CONTEXT = 200 // KV_LEN + 1 + private const val MASK_NEG = -10000f + + // Code Predictor KV-cache constants + private const val CP_LAYERS = 5 + private const val CP_KV_HEADS = 8 + private const val CP_HEAD_DIM = 128 + private const val CP_KV_LEN = 16 // max 16 past positions (17 total with current) + + // Talker .pte constants + private const val TALKER_PTE_KV_LEN = 100 // .pte talker KV window size + + // Codec special token IDs (in talker's 3072 vocab space) + private const val CODEC_EOS = 2150 + private const val CODEC_BOS = 2149 + private const val CODEC_PAD = 2148 + private const val CODEC_THINK = 2154 + private const val CODEC_NOTHINK = 2155 + private const val CODEC_THINK_BOS = 2156 + private const val CODEC_THINK_EOS = 2157 + private const val CODEC_LANG_FR = 2061 + + // Chat template token IDs (reduced vocab) + private const val IM_START = 1048 + private const val IM_END = 1049 + private const val TOKEN_USER = 872 + private const val TOKEN_ASSISTANT = 1042 + private const val TOKEN_NEWLINE = 198 + } + + private var ortEnv: OrtEnvironment? = null + private var talkerKv: OrtSession? = null // Talker ONNX CPU (fallback) + private var useHexagonTalker: Boolean = false // Use ggml-hexagon runner + private var cpKv: OrtSession? = null // CP KV-cache ONNX CPU + private var preConv: OrtSession? = null + private var preprocessor: OrtSession? = null + private var convDecoder: OrtSession? = null + private var decoderOnCpu: Boolean = false + private var decoderOnGpu: Boolean = false + + // Dual embedding tables for talker input + private var textEmbeds: FloatArray? = null // [1050, 1024] - pre-projected text embeddings + private var codecEmbedding: FloatArray? = null // [3072, 1024] - codec/control token embeddings + private var ttsBosEmbed: FloatArray? = null // [1024] - tts_bos text-side embedding + private var ttsEosEmbed: FloatArray? = null // [1024] - tts_eos text-side embedding + private var ttsPadEmbed: FloatArray? = null // [1024] - tts_pad text-side embedding + private var speakerEmbed: FloatArray? = null // [1024] - x-vector speaker embedding + + // VQ codebooks (loaded from numpy) + private var firstCodebook: FloatArray? = null // [2048, 256] + private var restCodebooks: Array? = null // 15 × [2048, 256] + private var firstOutputProj: FloatArray? = null // [512, 256] + private var restOutputProj: FloatArray? = null // [512, 256] + + // Code predictor embeddings [15, 2048, 1024] + private var cpEmbeddings: FloatArray? = null + + private var loaded = false + private var modelPath: String? = null + private var audioTrack: AudioTrack? = null + private var talkerUsesInt64Pos = false + private var talkerUsesCosSin = false + private var rotaryCos: FloatArray? = null + private var rotarySin: FloatArray? = null + private var cpUsesCosSin = false + private var cpRotaryCos: FloatArray? = null + private var cpRotarySin: FloatArray? = null + private var cpHeadsPath: String? = null // path dir for head_0..14.npy + private var cpHeadsCache: Array? = null // lazy-loaded heads cache (8MB each) + private var cpPteModule: org.pytorch.executorch.Module? = null // ExecuTorch CP on NPU (JNI) + private var talkerPteModule: org.pytorch.executorch.Module? = null // ExecuTorch talker on NPU (JNI) + private var talkerPteRotaryCos: FloatArray? = null + private var talkerPteRotarySin: FloatArray? = null + private var useEtCp: Boolean = false // CP via ExecuTorch runner process (root) + private var cpEtSocket: java.net.Socket? = null + private var cpFixedKv: Boolean = false // GPU uses fixed-size KV (shift + mask) + + private var debugLogFile: java.io.File? = null + + private fun nlog(msg: String) { + Log.i(TAG, msg) + Log.w(TAG, msg) + onLog?.invoke("[TTS] $msg") + try { + val f = debugLogFile ?: java.io.File("/data/local/tmp/kazeia/tts_debug.log") + f.appendText("${System.currentTimeMillis()} $msg\n") + } catch (_: Exception) { + // If /data/local/tmp fails, try app-internal dir + try { + val f2 = java.io.File("/data/local/tmp/kazeia/models/qwen3-tts-npu/tts_debug.log") + f2.appendText("${System.currentTimeMillis()} $msg\n") + debugLogFile = f2 + } catch (_: Exception) {} + } + } + + override suspend fun load(modelPath: String?, voiceId: String?) { + withContext(Dispatchers.IO) { + val path = modelPath ?: return@withContext + this@Qwen3TtsEngine.modelPath = path + try { + val t0 = System.currentTimeMillis() + ortEnv = OrtEnvironment.getEnvironment() + val htpPath = "$nativeLibDir/libQnnHtp.so" + + val qnnCacheDir = "$path/qnn_cache" + File(qnnCacheDir).mkdirs() + + fun loadQnn(name: String, fp16: Boolean = false): OrtSession { + val t = System.currentTimeMillis() + val opts = OrtSession.SessionOptions() + val qnnOpts = mutableMapOf( + "backend_path" to htpPath, + "qnn_context_cache_enable" to "1", + "qnn_context_cache_path" to "$qnnCacheDir/${name}.bin" + ) + if (fp16) { + qnnOpts["htp_graph_finalization_optimization_mode"] = "3" + qnnOpts["enable_htp_fp16_precision"] = "1" + } + opts.addQnn(qnnOpts) + val session = ortEnv!!.createSession("$path/$name/model.onnx", opts) + val mode = if (fp16) "QNN fp16" else "QNN" + nlog("$name ($mode): ${System.currentTimeMillis() - t}ms") + return session + } + + fun loadCpu(name: String): OrtSession { + val t = System.currentTimeMillis() + val opts = OrtSession.SessionOptions() + opts.setIntraOpNumThreads(6) + opts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) + val session = ortEnv!!.createSession("$path/$name/model.onnx", opts) + nlog("$name (CPU 6T): ${System.currentTimeMillis() - t}ms") + return session + } + + val gpuLib = "$nativeLibDir/libQnnGpu.so" + val hasGpu = File(gpuLib).exists() + + fun loadGpu(onnxPath: String, label: String): OrtSession { + val t = System.currentTimeMillis() + val opts = OrtSession.SessionOptions() + opts.addQnn(mapOf("backend_path" to gpuLib)) + val session = ortEnv!!.createSession(onnxPath, opts) + nlog("$label (GPU): ${System.currentTimeMillis() - t}ms") + return session + } + + // Speech decoder: try V2 ONNX CPU (correct weights), fall back to QNN HTP + val v2Path = "$path/v2_pre_conv" + if (File("$v2Path/model.onnx").exists()) { + nlog("Loading V2 speech decoder (CPU ONNX)...") + preConv = loadCpu("v2_pre_conv") + preprocessor = loadCpu("v2_pre_transformer") + convDecoder = loadCpu("v2_decoder_conv") + decoderOnCpu = true + nlog("Speech decoder V2 on CPU") + } else { + nlog("Loading speech decoder (QNN HTP)...") + preConv = loadQnn("pre_conv") + preprocessor = loadQnn("preprocessor") + convDecoder = loadQnn("conv_decoder") + nlog("Speech decoder on HTP") + } + + // Load CP .pte JNI BEFORE talker Hexagon (must grab CDSP first for skel path) + run { + val etModel = File("/data/local/tmp/kazeia/models/cp_transformer_fp16.pte") + if (etModel.exists() && cpPteModule == null) { + try { + // Set ADSP library path so FastRPC can find skel libs in app's native dir + android.system.Os.setenv("ADSP_LIBRARY_PATH", "$nativeLibDir;/data/local/tmp/kazeia/qnn_libs;/vendor/dsp/cdsp;/vendor/dsp", true) + nlog("ADSP_LIBRARY_PATH=$nativeLibDir") + val t0 = System.currentTimeMillis() + cpPteModule = org.pytorch.executorch.Module.load( + etModel.absolutePath, + org.pytorch.executorch.Module.LOAD_MODE_FILE, + 1 + ) + nlog("CP .pte JNI loaded: ${System.currentTimeMillis() - t0}ms") + val t1 = System.currentTimeMillis() + val lmResult = cpPteModule!!.loadMethod("forward") + nlog("CP .pte loadMethod: ${System.currentTimeMillis() - t1}ms, result=$lmResult") + if (lmResult != 0) { + nlog("CP .pte loadMethod failed ($lmResult), disabling JNI") + cpPteModule = null + } + } catch (e: Exception) { + nlog("CP .pte JNI failed: ${e.message}") + cpPteModule = null + } + } + // Load talker .pte JNI (same QNN context, no DSP contention with CP .pte) + val talkerPte = File("/data/local/tmp/kazeia/models/talker_transformer_fp16.pte") + if (talkerPte.exists() && cpPteModule != null && talkerPteModule == null) { + try { + val t0 = System.currentTimeMillis() + talkerPteModule = org.pytorch.executorch.Module.load( + talkerPte.absolutePath, + org.pytorch.executorch.Module.LOAD_MODE_FILE, + 1 + ) + val lm = talkerPteModule!!.loadMethod("forward") + nlog("Talker .pte JNI loaded+compiled: ${System.currentTimeMillis() - t0}ms, result=$lm") + if (lm != 0) { nlog("Talker .pte loadMethod failed"); talkerPteModule = null } + else { + // Load rotary tables for talker .pte + val path = "/data/local/tmp/kazeia/models" + talkerPteRotaryCos = loadNpy("$path/talker_pte_rotary_cos.npy") + talkerPteRotarySin = loadNpy("$path/talker_pte_rotary_sin.npy") + nlog("Talker .pte rotary: ${talkerPteRotaryCos?.size} floats") + } + } catch (e: Exception) { + nlog("Talker .pte JNI failed: ${e.message}") + talkerPteModule = null + } + } + } + + // Talker: skip Hexagon if .pte talker+CP are both loaded (avoids DSP contention) + val talkerT = System.currentTimeMillis() + val hexRunner = File("/data/local/tmp/kazeia/llama-hex/llama-tts-talker") + val hexModel = File("/data/local/tmp/kazeia/models/talker_f16.gguf") + val cpuOnnx = File("$path/talker_kv_cpu/model.onnx") + if (talkerPteModule != null && cpPteModule != null) { + nlog("Talker+CP using .pte JNI NPU — skipping Hexagon runners") + } else { + if (hexRunner.exists() && hexModel.exists()) { + if (hexStartRunner()) { + useHexagonTalker = true + nlog("Talker using Hexagon NPU (HMX FP16)") + } else { + nlog("Hexagon talker runner failed, using CPU") + } + } + val hexCpRunner = File("/data/local/tmp/kazeia/llama-hex/llama-tts-cp") + val hexCpModel = File("/data/local/tmp/kazeia/models/cp_f16.gguf") + if (hexCpRunner.exists() && hexCpModel.exists()) { + if (hexStartCpRunner()) { + useHexagonCp = true + nlog("CP using Hexagon NPU (HMX FP16)") + } else { + nlog("Hexagon CP runner failed, using CPU ONNX") + } + } + } + // Fallback: CPU ONNX for talker if hexagon failed + if (!useHexagonTalker) { + // Try new M-RoPE ONNX: GPU Adreno first (fast fp16), fallback CPU + val mropeOnnx = File("$path/talker_kv_cpu/model.onnx") + if (mropeOnnx.exists() && mropeOnnx.length() > 1_000_000) { + val talkerOpts = OrtSession.SessionOptions() + val gpuLib = "$nativeLibDir/libQnnGpu.so" + if (File(gpuLib).exists()) { + talkerOpts.addQnn(mapOf("backend_path" to gpuLib)) + nlog("Talker ONNX: loading on GPU Adreno...") + } else { + talkerOpts.setIntraOpNumThreads(6) + talkerOpts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) + nlog("Talker ONNX: loading on CPU 6T...") + } + talkerKv = ortEnv!!.createSession(mropeOnnx.absolutePath, talkerOpts) + talkerUsesCosSin = true + // Load rotary tables + val cosFile = File("$path/talker_kv_cpu/talker_rotary_cos.npy") + val sinFile = File("$path/talker_kv_cpu/talker_rotary_sin.npy") + if (cosFile.exists()) rotaryCos = loadNpy(cosFile.absolutePath) + if (sinFile.exists()) rotarySin = loadNpy(sinFile.absolutePath) + nlog("talker_kv M-RoPE (CPU 6T): ${System.currentTimeMillis() - talkerT}ms, cos/sin=${rotaryCos?.size}") + } else if (cpuOnnx.exists()) { + val talkerOpts = OrtSession.SessionOptions() + talkerOpts.setIntraOpNumThreads(6) + talkerOpts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) + talkerKv = ortEnv!!.createSession(cpuOnnx.absolutePath, talkerOpts) + talkerUsesInt64Pos = true + nlog("talker_kv legacy (CPU fp32 6T): ${System.currentTimeMillis() - talkerT}ms") + } else { + nlog("WARNING: No talker model available (no hexagon, no CPU ONNX)") + } + } + // Fallback: CPU ONNX for CP if hexagon failed + if (!useHexagonCp) { + val cpV2 = File("$path/cp_kv_v2/model.onnx") + if (cpV2.exists()) { + try { + // ExecuTorch CP runner process (TCP, root) — only needed when .pte CP unavailable + if (cpPteModule == null || (useHexagonTalker && talkerPteModule == null)) { + val etModel = File("/data/local/tmp/kazeia/models/cp_transformer_fp16.pte") + val etRunner = File("/data/local/tmp/kazeia/cp_et_runner") + if (etRunner.exists() && etModel.exists()) { + if (hexStartCpEtRunner()) { + useEtCp = true + nlog("CP using ExecuTorch NPU runner (TCP, root)") + } else { + nlog("CP ET runner failed to start") + } + } + } + // Also load ONNX CPU as fallback + cpKv = loadCpu("cp_kv_v2") + cpUsesCosSin = true + cpRotaryCos = loadNpy("$path/cp_kv_v2/cp_rotary_cos.npy") + cpRotarySin = loadNpy("$path/cp_kv_v2/cp_rotary_sin.npy") + // Heads loaded from file on-demand (125MB too big for RAM) + cpHeadsPath = "$path/cp_kv_v2/cp_heads.npy" + // codec_embs reuse cpEmbeddings already loaded + nlog("CP V2 ONNX loaded, cos/sin=${cpRotaryCos?.size}") + } catch (e: Exception) { + nlog("CP V2 ONNX failed: ${e.message}") + } + } else { + try { cpKv = loadCpu("cp_kv") } catch (e: Exception) { + nlog("CP ONNX failed: ${e.message} — CP will return zeros") + } + } + } + + // Load dual embedding tables for talker + textEmbeds = loadNpy("$path/text_embeds_projected.npy") + nlog("Text embeddings: ${textEmbeds!!.size / TALKER_DIM} × $TALKER_DIM") + codecEmbedding = loadNpy("$path/codec_embedding.npy") + nlog("Codec embedding: ${codecEmbedding!!.size / TALKER_DIM} × $TALKER_DIM") + val ttsSpecial = loadNpy("$path/tts_special_embeds.npy") // [3, 1024] = bos, eos, pad + ttsBosEmbed = ttsSpecial.sliceArray(0 until TALKER_DIM) + ttsEosEmbed = ttsSpecial.sliceArray(TALKER_DIM until 2 * TALKER_DIM) + ttsPadEmbed = ttsSpecial.sliceArray(2 * TALKER_DIM until 3 * TALKER_DIM) + nlog("TTS special embeddings loaded") + + // Load speaker embedding (x-vector for voice cloning) + val spkFile = File("$path/speaker_embedding.npy") + if (spkFile.exists()) { + speakerEmbed = loadNpy(spkFile.absolutePath) + nlog("Speaker embedding: ${speakerEmbed!!.size} floats, norm=${Math.sqrt(speakerEmbed!!.sumOf { (it * it).toDouble() })}") + } + + // Load VQ codebooks + loadVqCodebooks(path) + + // Load code predictor embeddings + cpEmbeddings = loadNpy("$path/code_predictor_embeddings.npy") + nlog("CP embeddings: ${cpEmbeddings!!.size} floats") + + loaded = true + nlog("Qwen3-TTS loaded in ${System.currentTimeMillis() - t0}ms") + } catch (e: Exception) { + nlog("ERROR: ${e.message}") + e.printStackTrace() + } + } + } + + override fun isLoaded(): Boolean = loaded + + fun setVoice(voicePath: String) { + nlog("Voice: $voicePath") + } + + override suspend fun synthesize(text: String, language: String): TtsResult { + return withContext(Dispatchers.IO) { + val audio = generateSpeech(text, language) + TtsResult(audioData = audio, sampleRate = SR, durationMs = audio.size * 1000L / SR) + } + } + + override suspend fun synthesizeAndPlay( + text: String, language: String, + onStart: (() -> Unit)?, onComplete: (() -> Unit)? + ) { + withContext(Dispatchers.IO) { + val codebooks = generateCodebooks(text, language) + if (codebooks == null) { onComplete?.invoke(); return@withContext } + + val (allCodebooks, numRealTokens) = codebooks + + // Release DSP for QNN decoder ONLY if decoder is on HTP (not GPU) + if (!decoderOnGpu && (useHexagonTalker || useHexagonCp)) { + hexStopRunner() + } + + onStart?.invoke() + + // Decode and play in streaming chunks + val track = AudioTrack.Builder() + .setAudioAttributes(AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()) + .setAudioFormat(AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(SR) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build()) + .setBufferSizeInBytes(SAMPLES_PER_TOKEN * 20 * 2) // ~1.6s buffer + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + track.play() + audioTrack = track + + val t3 = System.currentTimeMillis() + var pos = 0 + while (pos < numRealTokens) { + val chunkEnd = minOf(pos + EFFECTIVE_CHUNK, numRealTokens) + val chunkTokens = chunkEnd - pos + + // Build chunk codebooks padded to SEQ_LEN + val chunkCodes = Array(NUM_CODEBOOKS) { cb -> + IntArray(SEQ_LEN) { t -> + val srcIdx = pos + t + if (srcIdx < numRealTokens) allCodebooks[cb][srcIdx] else 0 + } + } + + val quantized = vqDecode(chunkCodes) + val chunkAudio = runSpeechDecoder(quantized) + + // Trim and crossfade + if (pos == 0) { + val keepSamples = minOf(chunkTokens * SAMPLES_PER_TOKEN, chunkAudio.size) + track.write(chunkAudio, 0, keepSamples) + } else { + val skipSamples = CHUNK_OVERLAP * SAMPLES_PER_TOKEN + val keepSamples = minOf(chunkTokens * SAMPLES_PER_TOKEN, chunkAudio.size - skipSamples) + if (keepSamples > 0 && skipSamples < chunkAudio.size) { + track.write(chunkAudio, skipSamples, keepSamples) + } + } + + pos += EFFECTIVE_CHUNK + } + nlog("Streaming decode: ${System.currentTimeMillis() - t3}ms") + + track.stop() + track.release() + audioTrack = null + onComplete?.invoke() + } + } + + override fun stop() { + audioTrack?.apply { + try { stop() } catch (_: Exception) {} + release() + } + audioTrack = null + } + + /** Generate codebooks only (no decode). Returns (allCodebooks[16][padLen], numRealTokens). */ + private fun generateCodebooks(text: String, language: String): Pair, Int>? { + if (!loaded) return null + val t0 = System.currentTimeMillis() + nlog("Generating codebooks: '$text' [$language]") + try { + val textEmbedsList: List + val phraseFile = java.io.File("$modelPath/phrase_embeds.bin") + if (phraseFile.exists()) { + val bytes = phraseFile.readBytes() + val buf = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val count = buf.int + textEmbedsList = (0 until count).map { + FloatArray(TALKER_DIM).also { arr -> buf.asFloatBuffer().get(arr); buf.position(buf.position() + TALKER_DIM * 4) } + } + nlog("Loaded $count pre-computed text embeddings") + } else { + val tokenIds = tokenizeText(text) + textEmbedsList = tokenIds.map { textEmb(it) } + } + val maxGen = MAX_CONTEXT - 15 + val allCodesArray = runInterleavedGeneration(textEmbedsList, maxGen) + val genMs = System.currentTimeMillis() - t0 + nlog("Interleaved gen: ${genMs}ms, ${allCodesArray.size} tokens") + if (allCodesArray.isEmpty()) return null + + val numRealTokens = allCodesArray.size + val padLen = maxOf(numRealTokens, SEQ_LEN) + val allCodebooks = Array(NUM_CODEBOOKS) { cb -> + IntArray(padLen) { t -> if (t < numRealTokens) allCodesArray[t][cb] else 0 } + } + return Pair(allCodebooks, numRealTokens) + } catch (e: Exception) { + nlog("ERROR: ${e.message}") + return null + } + } + + private fun generateSpeech(text: String, language: String): ShortArray { + if (!loaded) return ShortArray(0) + val t0 = System.currentTimeMillis() + nlog("Generating: '$text' [$language]") + + try { + // Step 1: Load pre-computed text embeddings, or use tokenizer fallback + val phraseFile = File("$modelPath/phrase_embeds.bin") + val textEmbedsList: List + if (phraseFile.exists()) { + // Pre-computed embeddings from PC + val bytes = phraseFile.readBytes() + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val count = buf.int + textEmbedsList = (0 until count).map { + FloatArray(TALKER_DIM).also { arr -> buf.asFloatBuffer().get(arr); buf.position(buf.position() + TALKER_DIM * 4) } + } + nlog("Loaded $count pre-computed text embeddings") + } else { + // Fallback: use reduced vocab tokenizer + val tokenIds = tokenizeText(text) + textEmbedsList = tokenIds.map { textEmb(it) } + nlog("Text tokens: ${tokenIds.size}: ${tokenIds.toList()}") + } + + // Step 2: Interleaved talker + code predictor → all 16 codebooks + val t1 = System.currentTimeMillis() + // Hard safety limit; actual stop is controlled by post-text step counter below. + val maxGen = MAX_CONTEXT - 15 + val allCodesArray = runInterleavedGeneration(textEmbedsList, maxGen) + nlog("Interleaved gen: ${System.currentTimeMillis() - t1}ms, ${allCodesArray.size} tokens") + if (allCodesArray.isEmpty()) return ShortArray(0) + + val numRealTokens = allCodesArray.size + + // Reshape to [16][totalTokens] for decoder (pad to SEQ_LEN if needed) + val padLen = maxOf(numRealTokens, SEQ_LEN) + val allCodebooks = Array(NUM_CODEBOOKS) { cb -> + IntArray(padLen) { t -> if (t < numRealTokens) allCodesArray[t][cb] else 0 } + } + + // Release DSP if decoder needs HTP (not GPU or CPU) + if (!decoderOnCpu && !decoderOnGpu && (useHexagonTalker || useHexagonCp)) { + hexStopRunner() + } + + // Step 3: VQ → decoder → audio (chunked) + val t3 = System.currentTimeMillis() + val audio = decodeChunked(allCodebooks, numRealTokens) + nlog("Decode (chunked): ${System.currentTimeMillis() - t3}ms") + + val totalMs = System.currentTimeMillis() - t0 + val audioDur = audio.size.toFloat() / SR + nlog("Total: ${totalMs}ms for ${audioDur}s audio (RTF ${totalMs / 1000f / audioDur})") + + return audio + } catch (e: Exception) { + nlog("ERROR: ${e.message}") + e.printStackTrace() + return ShortArray(0) + } + } + + // ==================== Tokenization ==================== + + /** + * Simple text tokenization. Maps known words to reduced vocab IDs. + * For production, this should be replaced with proper BPE tokenization. + */ + private fun tokenizeText(text: String): IntArray { + // Known word → reduced vocab ID mappings (from Qwen3 tokenizer + token_mapping) + val knownTokens = mapOf( + "Bonjour" to 1043, + "bonjour" to 1028, + " bonjour" to intArrayOf(220, 1028), // space + bonjour + "Oui" to 1006, + "Non" to 966, + "Merci" to 1035, + "salut" to 1023, + "." to 13, + "," to 11, + "!" to 0, + "?" to 30, + " " to 220, + ) + + // Try exact match first + val exactId = knownTokens[text] + if (exactId is Int) return intArrayOf(exactId) + + // Try word-by-word tokenization + val tokens = mutableListOf() + var remaining = text + while (remaining.isNotEmpty()) { + var matched = false + // Try longest match + for (len in minOf(remaining.length, 20) downTo 1) { + val candidate = remaining.substring(0, len) + val id = knownTokens[candidate] + if (id != null) { + when (id) { + is Int -> tokens.add(id) + is IntArray -> tokens.addAll(id.toList()) + } + remaining = remaining.substring(len) + matched = true + break + } + } + if (!matched) { + // Skip unknown character + nlog("WARN: Unknown token for '${remaining.first()}'") + remaining = remaining.substring(1) + } + } + + if (tokens.isEmpty()) { + nlog("WARN: No tokens from text '$text', using Bonjour fallback") + return intArrayOf(1043) // "Bonjour" + } + return tokens.toIntArray() + } + + // ==================== Talker KV-Cache Generation ==================== + + // ==================== Embedding Helpers ==================== + + private fun textEmb(reducedId: Int): FloatArray { + val e = FloatArray(TALKER_DIM) + System.arraycopy(textEmbeds!!, reducedId.coerceIn(0, 1049) * TALKER_DIM, e, 0, TALKER_DIM) + return e + } + + private fun codecEmb(codecIdx: Int): FloatArray { + val e = FloatArray(TALKER_DIM) + System.arraycopy(codecEmbedding!!, codecIdx.coerceIn(0, TALKER_VOCAB - 1) * TALKER_DIM, e, 0, TALKER_DIM) + return e + } + + private fun cpEmb(codebookIdx: Int, tokenIdx: Int): FloatArray { + // cpEmbeddings is [15, 2048, 1024] flattened + val e = FloatArray(TALKER_DIM) + val offset = (codebookIdx * CODEBOOK_SIZE + tokenIdx.coerceIn(0, CODEBOOK_SIZE - 1)) * TALKER_DIM + System.arraycopy(cpEmbeddings!!, offset, e, 0, TALKER_DIM) + return e + } + + private fun sumEmb(a: FloatArray, b: FloatArray): FloatArray { + val r = FloatArray(TALKER_DIM) + for (i in 0 until TALKER_DIM) r[i] = a[i] + b[i] + return r + } + + private fun addEmb(dst: FloatArray, src: FloatArray) { + for (i in 0 until TALKER_DIM) dst[i] += src[i] + } + + /** + * Build prefill embeddings with speaker embedding (voice cloning). + * textEmbedsList: pre-computed text embeddings (one per text token) + */ + private fun buildPrefillEmbeddings(textEmbedsList: List): List { + val padE = ttsPadEmbed ?: return emptyList() + val bosE = ttsBosEmbed ?: return emptyList() + val spkE = speakerEmbed + + val embeddings = mutableListOf() + + // 1. Role: <|im_start|>assistant\n + embeddings.add(textEmb(IM_START)) + embeddings.add(textEmb(TOKEN_ASSISTANT)) + embeddings.add(textEmb(TOKEN_NEWLINE)) + + // 2. Codec control: [think, think_bos, lang_fr, think_eos] + tts_pad + for (cc in intArrayOf(CODEC_THINK, CODEC_THINK_BOS, CODEC_LANG_FR, CODEC_THINK_EOS)) { + embeddings.add(sumEmb(padE, codecEmb(cc))) + } + + // 3. Speaker embedding (x-vector) + if (spkE != null) { + embeddings.add(sumEmb(padE, spkE)) + } + + // 4. codec_pad + tts_bos + embeddings.add(sumEmb(bosE, codecEmb(CODEC_PAD))) + + // 5. First text token + codec_bos + if (textEmbedsList.isNotEmpty()) { + embeddings.add(sumEmb(textEmbedsList[0], codecEmb(CODEC_BOS))) + } + + nlog("Prefill: ${embeddings.size} tokens (3 role + 4 ctrl + ${if (spkE != null) "1 spk + " else ""}1 bos + 1 text)") + return embeddings + } + + // ==================== Hexagon NPU Talker ==================== + + private val HEX_DIR = "/data/local/tmp/kazeia/llama-hex" + private val HEX_INPUT = "/data/local/tmp/kazeia/tts_input.bin" + private val HEX_OUTPUT = "/data/local/tmp/kazeia/tts_logits.bin" + private val HEX_CONTROL = "/data/local/tmp/kazeia/tts_control.txt" + private val TALKER_SOCK = "/data/local/tmp/kazeia/talker.sock" + private val CP_SOCK = "/data/local/tmp/kazeia/cp.sock" + private val CP_ET_SOCK = "/data/local/tmp/kazeia/cp_et.sock" + private var talkerSocket: android.net.LocalSocket? = null + private var cpSocket: android.net.LocalSocket? = null + private var useHexagonCp = false + + /** Run a command with su. Returns false if su is not available. */ + private fun suExec(cmd: String): Boolean { + return try { + Runtime.getRuntime().exec(arrayOf("su", "-c", cmd)).waitFor() + true + } catch (e: Exception) { + false + } + } + + /** Read a root-owned file via su. */ + private fun suReadFile(path: String): String { + val p = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $path")) + val text = p.inputStream.bufferedReader().readText().trim() + p.waitFor() + return text + } + + /** Start the hexagon talker runner and connect via socket. */ + private fun hexStartRunner(): Boolean { + nlog("Starting Hexagon talker runner...") + val t0 = System.currentTimeMillis() + if (!suExec("pkill -f llama-tts-talker")) { nlog("su not available"); return false } + Thread.sleep(200) + if (!suExec("cd $HEX_DIR && LD_LIBRARY_PATH=. nohup ./llama-tts-talker -m /data/local/tmp/kazeia/models/talker_f16.gguf -ngl 99 -mg 1 -s $TALKER_SOCK > /data/local/tmp/kazeia/tts_runner.log 2>&1 &")) return false + // Wait for socket to be connectable (30s max — model loading takes ~15s) + for (w in 0 until 300) { + Thread.sleep(100) + try { + val sock = android.net.LocalSocket() + sock.connect(android.net.LocalSocketAddress(TALKER_SOCK, android.net.LocalSocketAddress.Namespace.FILESYSTEM)) + talkerSocket = sock + nlog("Hexagon talker connected: ${System.currentTimeMillis() - t0}ms") + return true + } catch (_: Exception) {} + } + nlog("Hexagon talker timeout (30s)") + return false + } + + /** Talker forward via socket: send embedding, get hidden+logits. */ + private fun hexForward(embeddings: List): List> { + val sock = talkerSocket ?: return emptyList() + val os = sock.outputStream + val ins = sock.inputStream + val results = mutableListOf>() + + for (emb in embeddings) { + // Send "FWRD" + embedding + os.write("FWRD".toByteArray()) + val buf = java.nio.ByteBuffer.allocate(TALKER_DIM * 4).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (v in emb) buf.putFloat(v) + os.write(buf.array()) + os.flush() + + // Read hidden(1024*4) + logits(3072*4) = 16384 bytes + val respSize = (TALKER_DIM + TALKER_VOCAB) * 4 + val resp = ByteArray(respSize) + var read = 0 + while (read < respSize) { + val n = ins.read(resp, read, respSize - read) + if (n <= 0) break + read += n + } + val fb = java.nio.ByteBuffer.wrap(resp).order(java.nio.ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + val h = FloatArray(TALKER_DIM); fb.get(h) + val l = FloatArray(TALKER_VOCAB); fb.get(l) + results.add(Pair(h, l)) + } + return results + } + + /** Reset KV cache for a new phrase via socket. */ + private fun hexReset() { + val sock = talkerSocket ?: return + sock.outputStream.write("REST".toByteArray()) + sock.outputStream.flush() + val resp = ByteArray(4) + sock.inputStream.read(resp) // blocking, waits for "OK\0\0" + } + + /** Start CP ExecuTorch runner (NPU HTP via root process). */ + private fun hexStartCpEtRunner(): Boolean { + nlog("Connecting CP ExecuTorch runner (TCP:8790)...") + val t0 = System.currentTimeMillis() + // Try connecting to existing runner first + try { + val sock = java.net.Socket() + sock.connect(java.net.InetSocketAddress("127.0.0.1", 8790), 2000) + sock.tcpNoDelay = true + cpEtSocket = sock + nlog("CP ET connected (TCP, existing): ${System.currentTimeMillis() - t0}ms") + return true + } catch (e: Exception) { + nlog("CP ET existing connection failed: ${e.message}") + } + // Start new runner + nlog("No running CP ET, starting new...") + suExec("pkill -f cp_et_runner") + Thread.sleep(200) + if (!suExec("cd /data/local/tmp/kazeia && LD_LIBRARY_PATH=qnn_libs:. ADSP_LIBRARY_PATH=qnn_libs " + + "nohup ./cp_et_runner --model_path=models/cp_transformer_fp16.pte --tcp_port=8790 " + + "> /data/local/tmp/kazeia/cp_et.log 2>&1 &")) return false + for (w in 0 until 300) { + Thread.sleep(100) + try { + val sock = java.net.Socket("127.0.0.1", 8790) + sock.tcpNoDelay = true + cpEtSocket = sock + nlog("CP ET connected (TCP): ${System.currentTimeMillis() - t0}ms") + return true + } catch (_: Exception) {} + } + nlog("CP ET timeout (30s)") + return false + } + + /** CP via ExecuTorch NPU TCP socket: send hidden+cb0_emb, recv 15 codes + timing. */ + private fun etCpForward(pastHidden: FloatArray, cb0: Int): IntArray { + val sock = cpEtSocket + if (sock == null || sock.isClosed) { nlog("CP ET socket null"); return runCpV2(pastHidden, cb0) } + try { + val os = sock.getOutputStream(); val ins = sock.getInputStream() + val buf = java.nio.ByteBuffer.allocate(2 * TALKER_DIM * 4).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (v in pastHidden) buf.putFloat(v) + val cb0Emb = FloatArray(TALKER_DIM) + System.arraycopy(codecEmbedding!!, cb0.coerceIn(0, TALKER_VOCAB - 1) * TALKER_DIM, cb0Emb, 0, TALKER_DIM) + for (v in cb0Emb) buf.putFloat(v) + os.write(buf.array()); os.flush() + val resp = ByteArray(64); var read = 0 + while (read < 64) { val n = ins.read(resp, read, 64 - read); if (n <= 0) break; read += n } + if (read < 64) { nlog("CP ET read incomplete: $read/64"); return runCpV2(pastHidden, cb0) } + val codes = IntArray(15); val rb = java.nio.ByteBuffer.wrap(resp).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (i in 0 until 15) codes[i] = rb.int + return codes + } catch (e: Exception) { + nlog("CP ET error: ${e.message}"); cpEtSocket = null; useEtCp = false + return runCpV2(pastHidden, cb0) + } + } + + /** Start CP hexagon runner and connect via socket. */ + private fun hexStartCpRunner(): Boolean { + nlog("Starting CP Hexagon runner...") + val t0 = System.currentTimeMillis() + if (!suExec("pkill -f llama-tts-cp")) { nlog("su not available for CP"); return false } + Thread.sleep(200) + if (!suExec("cd $HEX_DIR && LD_LIBRARY_PATH=. nohup ./llama-tts-cp -m /data/local/tmp/kazeia/models/cp_f16.gguf -ngl 99 -mg 1 -s $CP_SOCK > /data/local/tmp/kazeia/cp_runner.log 2>&1 &")) return false + for (w in 0 until 300) { + Thread.sleep(100) + try { + val sock = android.net.LocalSocket() + sock.connect(android.net.LocalSocketAddress(CP_SOCK, android.net.LocalSocketAddress.Namespace.FILESYSTEM)) + cpSocket = sock + nlog("CP Hexagon connected: ${System.currentTimeMillis() - t0}ms") + return true + } catch (_: Exception) {} + } + nlog("CP Hexagon timeout (30s)") + return false + } + + /** CP via Hexagon NPU socket: send hidden+cb0_emb, get 15 codes. */ + private fun hexCpForward(pastHidden: FloatArray, cb0: Int): IntArray { + val sock = cpSocket + if (sock == null) { + nlog("CP socket NULL, falling back to CPU") + return runCpCpu(pastHidden, cb0) + } + try { + val os = sock.outputStream + val ins = sock.inputStream + + val t0 = System.currentTimeMillis() + // Send hidden(1024) + cb0_emb(1024) = 8192 bytes + val buf = java.nio.ByteBuffer.allocate(2 * TALKER_DIM * 4).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (v in pastHidden) buf.putFloat(v) + val cb0Emb = FloatArray(TALKER_DIM) + System.arraycopy(codecEmbedding!!, cb0.coerceIn(0, TALKER_VOCAB - 1) * TALKER_DIM, cb0Emb, 0, TALKER_DIM) + for (v in cb0Emb) buf.putFloat(v) + os.write(buf.array()); os.flush() + val writeMs = System.currentTimeMillis() - t0 + + // Read 15 codes (60 bytes) + timing (4 bytes) = 64 bytes + val resp = ByteArray(64) + var read = 0 + while (read < 64) { val n = ins.read(resp, read, 64 - read); if (n <= 0) break; read += n } + val totalMs = System.currentTimeMillis() - t0 + + if (cpCallCount <= 3) nlog("CP socket: write=${writeMs}ms, total=${totalMs}ms, read=$read/64") + + if (read < 64) { + nlog("CP socket read incomplete: $read/64 bytes") + return runCpCpu(pastHidden, cb0) + } + + val codes = IntArray(15) + val rb = java.nio.ByteBuffer.wrap(resp).order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (i in 0 until 15) codes[i] = rb.int + return codes + } catch (e: Exception) { + nlog("CP socket error: ${e.message}, falling back to CPU") + cpSocket = null + return runCpCpu(pastHidden, cb0) + } + } + + /** Ensure hexagon runners are alive. Only restarts if they're not connected. */ + private fun ensureHexagonRunners() { + if (!useHexagonTalker) { + val hexRunner = java.io.File("/data/local/tmp/kazeia/llama-hex/llama-tts-talker") + val hexModel = java.io.File("/data/local/tmp/kazeia/models/talker_f16.gguf") + if (hexRunner.exists() && hexModel.exists() && hexStartRunner()) { + useHexagonTalker = true + } + } + if (!useHexagonCp) { + val hexCpRunner = java.io.File("/data/local/tmp/kazeia/llama-hex/llama-tts-cp") + val hexCpModel = java.io.File("/data/local/tmp/kazeia/models/cp_f16.gguf") + if (hexCpRunner.exists() && hexCpModel.exists() && hexStartCpRunner()) { + useHexagonCp = true + } + } + } + + /** Stop all runners and release DSP for QNN decode. Runners restart on next generate(). */ + private fun hexStopRunner() { + try { + talkerSocket?.outputStream?.write("QUIT".toByteArray()) + talkerSocket?.close() + cpSocket?.close() + cpEtSocket?.close() + Thread.sleep(300) + suExec("pkill -f llama-tts") + suExec("pkill -f cp_et_runner") + Thread.sleep(200) + } catch (_: Exception) {} + talkerSocket = null; cpSocket = null; cpEtSocket = null + useHexagonTalker = false; useHexagonCp = false; useEtCp = false + nlog("Hexagon runners stopped, DSP released") + } + + /** Full interleaved generation using Hexagon NPU talker + CPU CP. */ + /** All-NPU pipeline: talker .pte + CP .pte via JNI, no root. */ + private fun runInterleavedPte(textEmbedsList: List, maxGenTokens: Int): Array { + val talkerMod = talkerPteModule!! + val cpMod = cpPteModule!! + val tCos = talkerPteRotaryCos!! + val tSin = talkerPteRotarySin!! + val cCos = cpRotaryCos ?: return emptyArray() + val cSin = cpRotarySin ?: return emptyArray() + val eosE = ttsEosEmbed ?: return emptyArray() + val padE = ttsPadEmbed ?: return emptyArray() + + val prefill = buildPrefillEmbeddings(textEmbedsList) + if (prefill.isEmpty()) return emptyArray() + val trailingEmbeds = if (textEmbedsList.size > 1) textEmbedsList.subList(1, textEmbedsList.size) else emptyList() + var trailingIdx = 0 + + val allCodes = mutableListOf() + val generatedCb0 = mutableListOf() + + // Talker KV caches [TALKER_LAYERS × (k,v)] each [1, 8, TALKER_PTE_KV_LEN, 128] + val tkvSize = TALKER_HEADS * TALKER_PTE_KV_LEN * TALKER_HEAD_DIM + var tK = Array(TALKER_LAYERS) { FloatArray(tkvSize) } + var tV = Array(TALKER_LAYERS) { FloatArray(tkvSize) } + val maskData = FloatArray(TALKER_PTE_KV_LEN) { -1e9f } + + var pos = 0; var currentCb0 = -1; var pastHidden: FloatArray? = null + + nlog("PTE pipeline: prefill=${prefill.size}, trailing=${trailingEmbeds.size}") + + // ===== PREFILL ===== + val tPrefill = System.currentTimeMillis() + for (step in prefill.indices) { + // Unmask position + val maskIdx = TALKER_PTE_KV_LEN - 1 - minOf(pos, TALKER_PTE_KV_LEN - 1) + if (maskIdx >= 0) maskData[maskIdx] = 0f + + val cosSlice = FloatArray(TALKER_HEAD_DIM) + System.arraycopy(tCos, pos * TALKER_HEAD_DIM, cosSlice, 0, TALKER_HEAD_DIM) + val sinSlice = FloatArray(TALKER_HEAD_DIM) + System.arraycopy(tSin, pos * TALKER_HEAD_DIM, sinSlice, 0, TALKER_HEAD_DIM) + + val inputs = mutableListOf( + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(prefill[step], longArrayOf(1, 1, TALKER_DIM.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(maskData.clone(), longArrayOf(1, 1, 1, TALKER_PTE_KV_LEN.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(cosSlice, longArrayOf(1, 1, TALKER_HEAD_DIM.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(sinSlice, longArrayOf(1, 1, TALKER_HEAD_DIM.toLong()))) + ) + for (i in 0 until TALKER_LAYERS) { + inputs.add(org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(tK[i], longArrayOf(1, TALKER_HEADS.toLong(), TALKER_PTE_KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())))) + inputs.add(org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(tV[i], longArrayOf(1, TALKER_HEADS.toLong(), TALKER_PTE_KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())))) + } + + val out = talkerMod.forward(*inputs.toTypedArray()) + pastHidden = out[0].toTensor().dataAsFloatArray + val logits = out[1].toTensor().dataAsFloatArray + for (i in 0 until TALKER_LAYERS) { + tK[i] = out[2 + i * 2].toTensor().dataAsFloatArray + tV[i] = out[3 + i * 2].toTensor().dataAsFloatArray + } + pos++ + + if (step == prefill.size - 1) { + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + currentCb0 = sampleTopK(logits, 0.9f, 50) + } + } + nlog("Prefill (PTE): ${System.currentTimeMillis() - tPrefill}ms, ${prefill.size} steps") + + if (currentCb0 < 0 || currentCb0 == CODEC_EOS) return emptyArray() + + // ===== INTERLEAVED GENERATION ===== + var totalTalkerMs = 0L; var totalCpMs = 0L + for (genStep in 0 until maxGenTokens) { + val codes = IntArray(NUM_CODEBOOKS); codes[0] = currentCb0 + + // 1. CP: predict CB1-15 + val tCp = System.currentTimeMillis() + val cpCodes = runCpPte(pastHidden!!, currentCb0) + totalCpMs += System.currentTimeMillis() - tCp + for (cb in 1 until NUM_CODEBOOKS) codes[cb] = cpCodes[cb - 1] + allCodes.add(codes); generatedCb0.add(currentCb0) + + if (genStep < 3) nlog("Step ${genStep+1}: cb0=$currentCb0 cb1=${codes[1]}") + + // 2. Build next talker input + val codecSum = FloatArray(TALKER_DIM) + addEmb(codecSum, codecEmb(codes[0])) + for (cb in 1 until NUM_CODEBOOKS) addEmb(codecSum, cpEmb(cb - 1, codes[cb])) + + val nextEmbed: FloatArray = when { + trailingIdx < trailingEmbeds.size -> { trailingIdx++; sumEmb(codecSum, trailingEmbeds[trailingIdx - 1]) } + trailingIdx == trailingEmbeds.size -> { trailingIdx++; sumEmb(codecSum, eosE) } + else -> sumEmb(codecSum, padE) + } + + // 3. Talker step + val maskIdx = TALKER_PTE_KV_LEN - 1 - minOf(pos, TALKER_PTE_KV_LEN - 1) + if (maskIdx >= 0) maskData[maskIdx] = 0f + + val cosSlice = FloatArray(TALKER_HEAD_DIM) + System.arraycopy(tCos, minOf(pos, tCos.size / TALKER_HEAD_DIM - 1) * TALKER_HEAD_DIM, cosSlice, 0, TALKER_HEAD_DIM) + val sinSlice = FloatArray(TALKER_HEAD_DIM) + System.arraycopy(tSin, minOf(pos, tSin.size / TALKER_HEAD_DIM - 1) * TALKER_HEAD_DIM, sinSlice, 0, TALKER_HEAD_DIM) + + val inputs = mutableListOf( + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(nextEmbed, longArrayOf(1, 1, TALKER_DIM.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(maskData.clone(), longArrayOf(1, 1, 1, TALKER_PTE_KV_LEN.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(cosSlice, longArrayOf(1, 1, TALKER_HEAD_DIM.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(sinSlice, longArrayOf(1, 1, TALKER_HEAD_DIM.toLong()))) + ) + for (i in 0 until TALKER_LAYERS) { + inputs.add(org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(tK[i], longArrayOf(1, TALKER_HEADS.toLong(), TALKER_PTE_KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())))) + inputs.add(org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(tV[i], longArrayOf(1, TALKER_HEADS.toLong(), TALKER_PTE_KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())))) + } + + val tTalker = System.currentTimeMillis() + val out = talkerMod.forward(*inputs.toTypedArray()) + totalTalkerMs += System.currentTimeMillis() - tTalker + + pastHidden = out[0].toTensor().dataAsFloatArray + val logits = out[1].toTensor().dataAsFloatArray + for (i in 0 until TALKER_LAYERS) { + tK[i] = out[2 + i * 2].toTensor().dataAsFloatArray + tV[i] = out[3 + i * 2].toTensor().dataAsFloatArray + } + pos++ + + // 4. Next CB0 + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + val seen = HashSet(); for (prev in generatedCb0) seen.add(prev) + for (tok in seen) { logits[tok] = if (logits[tok] > 0) logits[tok] / 1.05f else logits[tok] * 1.05f } + val nextCb0 = sampleTopK(logits, 0.9f, 50) + + if (nextCb0 == CODEC_EOS) { nlog("EOS at gen step ${genStep + 2}"); break } + if (generatedCb0.size >= 9 && generatedCb0.takeLast(9).all { it == nextCb0 }) { + nlog("Degeneration at step ${genStep + 2}"); break + } + currentCb0 = nextCb0 + } + + val n = allCodes.size + nlog("Generated $n tokens | Talker(PTE): ${totalTalkerMs}ms (${totalTalkerMs/maxOf(n,1)}ms/step) | CP(PTE): ${totalCpMs}ms (${totalCpMs/maxOf(n,1)}ms/step)") + return allCodes.toTypedArray() + } + + private fun runInterleavedHexagon(textEmbedsList: List, maxGenTokens: Int): Array { + val eosE = ttsEosEmbed ?: return emptyArray() + val padE = ttsPadEmbed ?: return emptyArray() + + val prefill = buildPrefillEmbeddings(textEmbedsList) + if (prefill.isEmpty()) return emptyArray() + val trailingEmbeds = if (textEmbedsList.size > 1) textEmbedsList.subList(1, textEmbedsList.size) else emptyList() + var trailingIdx = 0 + val allCodes = mutableListOf() + val generatedCb0 = mutableListOf() + + // Reset KV cache for new phrase (runner already started at load time) + hexReset() + + var totalTalkerMs = 0L; var totalCpMs = 0L + + try { + // Prefill + val tPrefill = System.currentTimeMillis() + val prefillResults = hexForward(prefill) + val prefillMs = System.currentTimeMillis() - tPrefill + nlog("Prefill (Hexagon): ${prefillMs}ms, ${prefillResults.size} steps") + + var pastHidden = prefillResults.last().first + val prefillLogits = prefillResults.last().second + // Suppress non-codec tokens + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) prefillLogits[j] = Float.NEGATIVE_INFINITY } + var currentCb0 = sampleTopK(prefillLogits, 0.9f, 50) + nlog("Prefill done: first cb0=$currentCb0") + + // Generation loop + for (genStep in 0 until maxGenTokens) { + // CP on CPU + val tCp = System.currentTimeMillis() + val codes = IntArray(NUM_CODEBOOKS) + codes[0] = currentCb0 + val cpCodes = runCodePredictorInterleaved(pastHidden, currentCb0) + for (cb in 1 until NUM_CODEBOOKS) codes[cb] = cpCodes[cb - 1] + allCodes.add(codes) + generatedCb0.add(currentCb0) + val cpMs = System.currentTimeMillis() - tCp + totalCpMs += cpMs + + if (genStep < 3) nlog("Gen step ${genStep + 1}: cb0=$currentCb0 cb1=${codes[1]} [CP=${cpMs}ms]") + + // Build next embedding + val codecSum = FloatArray(TALKER_DIM) + addEmb(codecSum, codecEmb(codes[0])) + for (cb in 1 until NUM_CODEBOOKS) addEmb(codecSum, cpEmb(cb - 1, codes[cb])) + + val nextEmbed: FloatArray = if (trailingIdx < trailingEmbeds.size) { + sumEmb(codecSum, trailingEmbeds[trailingIdx++]) + } else if (trailingIdx == trailingEmbeds.size) { + trailingIdx++; sumEmb(codecSum, eosE) + } else { + sumEmb(codecSum, padE) + } + + // Talker forward on Hexagon NPU + val tTalker = System.currentTimeMillis() + val results = hexForward(listOf(nextEmbed)) + val talkerMs = System.currentTimeMillis() - tTalker + totalTalkerMs += talkerMs + if (genStep < 3) nlog(" Talker(HEX)=${talkerMs}ms") + + pastHidden = results[0].first + val logits = results[0].second + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + val seen = HashSet(); for (prev in generatedCb0) seen.add(prev) + for (tok in seen) { logits[tok] = if (logits[tok] > 0) logits[tok] / 1.05f else logits[tok] * 1.05f } + val nextCb0 = sampleTopK(logits, 0.9f, 50) + + if (nextCb0 == CODEC_EOS) { nlog("EOS at gen step ${genStep + 2}"); break } + if (generatedCb0.size >= 9 && generatedCb0.takeLast(9).all { it == nextCb0 }) { + nlog("Degeneration at step ${genStep + 2}"); break + } + currentCb0 = nextCb0 + } + } finally { + // Runners stay alive here — hexStopRunner() is called before QNN decode + } + + val n = allCodes.size + nlog("Generated $n tokens | Talker(HEX): ${totalTalkerMs}ms (${totalTalkerMs/maxOf(n,1)}ms/step) | CP: ${totalCpMs}ms (${totalCpMs/maxOf(n,1)}ms/step)") + return allCodes.toTypedArray() + } + + /** + * Run interleaved talker + code predictor pipeline. + * + * At each step: + * 1. Talker forward → logits + hidden_state + * 2. Code predictor (hidden, cb0_emb) → CB1-15 autoregressively + * 3. Sum all 16 codebook embeddings + trailing text → next talker input + * + * Returns all 16 codebooks as [numTokens][16]. + */ + private fun runInterleavedGeneration(textEmbedsList: List, maxGenTokens: Int = 50): Array { + // Priority 1: All .pte JNI on NPU (no root needed) + if (talkerPteModule != null && cpPteModule != null) { + return runInterleavedPte(textEmbedsList, maxGenTokens) + } + // Priority 2: Hexagon talker + socket/ONNX CP + ensureHexagonRunners() + if (useHexagonTalker) { + return runInterleavedHexagon(textEmbedsList, maxGenTokens) + } + val env = ortEnv ?: return emptyArray() + if (talkerKv == null) return emptyArray() + val session = talkerKv // may be null if using NPU + val eosE = ttsEosEmbed ?: return emptyArray() + val padE = ttsPadEmbed ?: return emptyArray() + + val prefill = buildPrefillEmbeddings(textEmbedsList) + if (prefill.isEmpty()) return emptyArray() + + // Trailing text embeddings (all except first, which is in prefill) + val trailingEmbeds = if (textEmbedsList.size > 1) textEmbedsList.subList(1, textEmbedsList.size) else emptyList() + var trailingIdx = 0 + + val allCodes = mutableListOf() // each entry is [16] codebooks for one time step + val generatedCb0 = mutableListOf() + + val kCacheSize = TALKER_HEADS * TALKER_HEAD_DIM * KV_LEN + val vCacheSize = TALKER_HEADS * KV_LEN * TALKER_HEAD_DIM + var kCaches = Array(TALKER_LAYERS) { FloatArray(kCacheSize) } + var vCaches = Array(TALKER_LAYERS) { FloatArray(vCacheSize) } + val maskData = FloatArray(MAX_CONTEXT) { MASK_NEG } + + var pos = 0 + var currentCb0 = -1 + var pastHidden: FloatArray? = null + + // ===== PREFILL ===== + for (step in prefill.indices) { + maskData[MAX_CONTEXT - 1 - step] = 0f + val res = runTalkerStep(env, session!!, prefill[step], maskData, pos, kCaches, vCaches) + kCaches = res.newK; vCaches = res.newV; pastHidden = res.hidden + pos++ + + if (step == prefill.size - 1) { + // Apply suppression + sampling to get first codec_0 + val logits = res.logits + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + currentCb0 = sampleTopK(logits, 0.9f, 50) + nlog("Prefill done: first cb0=$currentCb0") + } + } + + if (currentCb0 < 0 || currentCb0 == CODEC_EOS) return emptyArray() + + // ===== INTERLEAVED GENERATION ===== + var totalTalkerMs = 0L; var totalCpMs = 0L + for (genStep in 0 until maxGenTokens) { + // 1. Run code predictor: (pastHidden, cb0_emb) → CB1-15 + val codes = IntArray(NUM_CODEBOOKS) + codes[0] = currentCb0 + + val tCp = System.currentTimeMillis() + val cpCodes = runCodePredictorInterleaved(pastHidden!!, currentCb0) + val cpMs = System.currentTimeMillis() - tCp + totalCpMs += cpMs + for (cb in 1 until NUM_CODEBOOKS) codes[cb] = cpCodes[cb - 1] + allCodes.add(codes) + generatedCb0.add(currentCb0) + + if (genStep < 3) { + nlog("Gen step ${genStep + 1}: cb0=$currentCb0 cb1=${codes[1]} [CP=${cpMs}ms]") + } + + // 2. Build next talker input: sum of ALL 16 codebook embeddings + trailing text + val codecSum = FloatArray(TALKER_DIM) + addEmb(codecSum, codecEmb(codes[0])) + for (cb in 1 until NUM_CODEBOOKS) { + addEmb(codecSum, cpEmb(cb - 1, codes[cb])) + } + + // Text side: trailing text tokens, then tts_eos, then tts_pad (NOT nothing!) + // Python model expects tts_pad after text exhaustion - crucial for EOS convergence + val nextEmbed: FloatArray + if (trailingIdx < trailingEmbeds.size) { + nextEmbed = sumEmb(codecSum, trailingEmbeds[trailingIdx]) + trailingIdx++ + } else if (trailingIdx == trailingEmbeds.size) { + nextEmbed = sumEmb(codecSum, eosE) + trailingIdx++ + } else { + nextEmbed = sumEmb(codecSum, padE) + } + + // 3. Run talker step + maskData[MAX_CONTEXT - 1 - pos] = 0f + val tTalker = System.currentTimeMillis() + val res = runTalkerStep(env, session!!, nextEmbed, maskData, pos, kCaches, vCaches) + val talkerMs = System.currentTimeMillis() - tTalker + totalTalkerMs += talkerMs + kCaches = res.newK; vCaches = res.newV; pastHidden = res.hidden + pos++ + + if (genStep < 3) { + nlog(" Talker=${talkerMs}ms") + } + + // 4. Get next cb0 from logits + val logits = res.logits + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + // HuggingFace repetition penalty: 1.05x once per unique token in history + val seen = HashSet() + for (prev in generatedCb0) seen.add(prev) + for (tok in seen) { + logits[tok] = if (logits[tok] > 0) logits[tok] / 1.05f else logits[tok] * 1.05f + } + val nextCb0 = sampleTopK(logits, 0.9f, 50) + + if (nextCb0 == CODEC_EOS) { + nlog("EOS at gen step ${genStep + 2}") + break + } + // Safety: stop on extreme repetition (10+ identical = model degeneration) + if (generatedCb0.size >= 9) { + val last9 = generatedCb0.takeLast(9) + if (last9.all { it == nextCb0 }) { + nlog("Degeneration at step ${genStep + 2} (token $nextCb0 × 10), stopping") + break + } + } + currentCb0 = nextCb0 + } + + val n = allCodes.size + nlog("Generated $n tokens | Talker: ${totalTalkerMs}ms (${totalTalkerMs/maxOf(n,1)}ms/step) | CP: ${totalCpMs}ms (${totalCpMs/maxOf(n,1)}ms/step)") + return allCodes.toTypedArray() + } + + /** Single talker step: returns logits, hidden, and updated KV caches */ + private data class TalkerStepResult( + val logits: FloatArray, val hidden: FloatArray, + val newK: Array, val newV: Array + ) + + private fun runTalkerStep( + env: OrtEnvironment, session: OrtSession, + inputEmbed: FloatArray, maskData: FloatArray, pos: Int, + kCaches: Array, vCaches: Array + ): TalkerStepResult { + if (talkerUsesCosSin) { + return runTalkerStepMRoPE(env, session, inputEmbed, maskData, pos, kCaches, vCaches) + } + val embedTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputEmbed), longArrayOf(1, 1, TALKER_DIM.toLong())) + val maskTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(maskData.clone()), longArrayOf(1, 1, 1, MAX_CONTEXT.toLong())) + val posTensor = if (talkerUsesInt64Pos) { + OnnxTensor.createTensor(env, java.nio.LongBuffer.wrap(longArrayOf(pos.toLong())), longArrayOf(1)) + } else { + OnnxTensor.createTensor(env, IntBuffer.wrap(intArrayOf(pos)), longArrayOf(1)) + } + + val inputs = LinkedHashMap() + inputs["inputs_embeds"] = embedTensor + inputs["attention_mask"] = maskTensor + for (i in 0 until TALKER_LAYERS) { + inputs["k_${i}_in"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(kCaches[i]), + longArrayOf(1, TALKER_HEADS.toLong(), TALKER_HEAD_DIM.toLong(), KV_LEN.toLong())) + inputs["v_${i}_in"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(vCaches[i]), + longArrayOf(1, TALKER_HEADS.toLong(), KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())) + } + inputs["position_ids"] = posTensor + + val result = session.run(inputs) + + // logits [1, 3072, 1, 1] + val logits = FloatArray(TALKER_VOCAB) + (result.get(0) as OnnxTensor).floatBuffer.get(logits) + + // hidden [1, 1, 1024] at output index 57 + val hidden = FloatArray(TALKER_DIM) + (result.get(57) as OnnxTensor).floatBuffer.get(hidden) + + // KV caches + val kCacheSize = TALKER_HEADS * TALKER_HEAD_DIM * KV_LEN + val vCacheSize = TALKER_HEADS * KV_LEN * TALKER_HEAD_DIM + val newK = Array(TALKER_LAYERS) { FloatArray(kCacheSize) } + val newV = Array(TALKER_LAYERS) { FloatArray(vCacheSize) } + for (i in 0 until TALKER_LAYERS) { + (result.get(1 + i * 2) as OnnxTensor).floatBuffer.get(newK[i]) + (result.get(2 + i * 2) as OnnxTensor).floatBuffer.get(newV[i]) + } + + for ((_, v) in inputs) v.close() + result.close() + + return TalkerStepResult(logits, hidden, newK, newV) + } + + /** New talker step with M-RoPE: cos/sin inputs, KV shape [1,8,199,128]. */ + private fun runTalkerStepMRoPE( + env: OrtEnvironment, session: OrtSession, + inputEmbed: FloatArray, maskData: FloatArray, pos: Int, + kCaches: Array, vCaches: Array + ): TalkerStepResult { + val cos = rotaryCos ?: return TalkerStepResult(FloatArray(TALKER_VOCAB), FloatArray(TALKER_DIM), kCaches, vCaches) + val sin = rotarySin ?: return TalkerStepResult(FloatArray(TALKER_VOCAB), FloatArray(TALKER_DIM), kCaches, vCaches) + + val inputs = LinkedHashMap() + inputs["inputs_embeds"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputEmbed), longArrayOf(1, 1, TALKER_DIM.toLong())) + inputs["attention_mask"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(maskData.clone()), longArrayOf(1, 1, 1, MAX_CONTEXT.toLong())) + + // cos/sin for this position: [1, 1, 128] + val cosSlice = FloatArray(TALKER_HEAD_DIM) + val sinSlice = FloatArray(TALKER_HEAD_DIM) + System.arraycopy(cos, pos * TALKER_HEAD_DIM, cosSlice, 0, TALKER_HEAD_DIM) + System.arraycopy(sin, pos * TALKER_HEAD_DIM, sinSlice, 0, TALKER_HEAD_DIM) + inputs["cos"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(cosSlice), longArrayOf(1, 1, TALKER_HEAD_DIM.toLong())) + inputs["sin"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(sinSlice), longArrayOf(1, 1, TALKER_HEAD_DIM.toLong())) + + // KV caches [1, 8, 199, 128] (NOT transposed like legacy) + val kvSize = TALKER_HEADS * KV_LEN * TALKER_HEAD_DIM // 8 * 199 * 128 + for (i in 0 until TALKER_LAYERS) { + inputs["k_${i}_in"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(kCaches[i]), + longArrayOf(1, TALKER_HEADS.toLong(), KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())) + inputs["v_${i}_in"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(vCaches[i]), + longArrayOf(1, TALKER_HEADS.toLong(), KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())) + } + + val result = session.run(inputs) + + // New format: hidden at index 0 [1,1,1024], logits at index 1 [1,1,3072] + val hidden = FloatArray(TALKER_DIM) + (result.get(0) as OnnxTensor).floatBuffer.get(hidden) + val logits = FloatArray(TALKER_VOCAB) + (result.get(1) as OnnxTensor).floatBuffer.get(logits) + + // KV caches out: [1, 8, 200, 128] — trim to [1, 8, 199, 128] (drop oldest) + val newK = Array(TALKER_LAYERS) { i -> + val full = FloatArray(TALKER_HEADS * MAX_CONTEXT * TALKER_HEAD_DIM) + (result.get(2 + i * 2) as OnnxTensor).floatBuffer.get(full) + // Drop first position: copy [1:200] → [0:199] + val trimmed = FloatArray(kvSize) + System.arraycopy(full, TALKER_HEADS * TALKER_HEAD_DIM, trimmed, 0, kvSize) + // Wait — full is [1,8,200,128] flattened. Drop pos 0 from dim 2: + // For each head h: copy full[h*200*128 + 1*128 .. h*200*128 + 200*128] → trimmed[h*199*128..] + val t = FloatArray(kvSize) + for (h in 0 until TALKER_HEADS) { + System.arraycopy(full, h * MAX_CONTEXT * TALKER_HEAD_DIM + TALKER_HEAD_DIM, + t, h * KV_LEN * TALKER_HEAD_DIM, KV_LEN * TALKER_HEAD_DIM) + } + t + } + val newV = Array(TALKER_LAYERS) { i -> + val full = FloatArray(TALKER_HEADS * MAX_CONTEXT * TALKER_HEAD_DIM) + (result.get(3 + i * 2) as OnnxTensor).floatBuffer.get(full) + val t = FloatArray(kvSize) + for (h in 0 until TALKER_HEADS) { + System.arraycopy(full, h * MAX_CONTEXT * TALKER_HEAD_DIM + TALKER_HEAD_DIM, + t, h * KV_LEN * TALKER_HEAD_DIM, KV_LEN * TALKER_HEAD_DIM) + } + t + } + + for ((_, v) in inputs) v.close() + result.close() + + return TalkerStepResult(logits, hidden, newK, newV) + } + + + /** Run code predictor — JNI .pte > TCP runner > Hexagon > CPU ONNX. */ + private var cpCallCount = 0 + private fun runCodePredictorInterleaved(pastHidden: FloatArray, cb0: Int): IntArray { + if (cpCallCount == 0) nlog("CP: pte=${cpPteModule != null}, talkerPte=${talkerPteModule != null}, et=$useEtCp, hex=$useHexagonCp, v2=$cpUsesCosSin") + cpCallCount++ + // JNI .pte only works when talker is also .pte (same QNN context, no DSP contention) + if (cpPteModule != null && talkerPteModule != null) return runCpPte(pastHidden, cb0) + if (useEtCp) return etCpForward(pastHidden, cb0) + if (useHexagonCp) return hexCpForward(pastHidden, cb0) + if (cpUsesCosSin && cpKv != null) return runCpV2(pastHidden, cb0) + return runCpCpu(pastHidden, cb0) + } + + /** CP via ExecuTorch .pte on NPU (QNN fp16). + * .pte inputs: emb[1,1,1024], mask[1,1,1,17], cos[1,1,128], sin[1,1,128], 10×kv[1,8,16,128] + * .pte outputs: hidden[1,1,1024], head_logits[1,15,2048], 10×kv[1,8,17,128] + */ + private fun runCpPte(pastHidden: FloatArray, cb0: Int): IntArray { + val module = cpPteModule ?: return runCpV2(pastHidden, cb0) + val cos = cpRotaryCos ?: return runCpV2(pastHidden, cb0) + val sin = cpRotarySin ?: return runCpV2(pastHidden, cb0) + + val codes = IntArray(15) + // Fixed KV caches [1, 8, 16, 128] + val kvSize = CP_KV_HEADS * CP_KV_LEN * CP_HEAD_DIM + var kCaches = Array(CP_LAYERS) { FloatArray(kvSize) } + var vCaches = Array(CP_LAYERS) { FloatArray(kvSize) } + + var emb = pastHidden + try { + for (step in 0 until 17) { + if (step == 1) { + emb = FloatArray(TALKER_DIM) + System.arraycopy(codecEmbedding!!, cb0.coerceIn(0, TALKER_VOCAB - 1) * TALKER_DIM, emb, 0, TALKER_DIM) + } else if (step >= 2) { + emb = FloatArray(TALKER_DIM) + val cembs = cpEmbeddings ?: return codes + val off = ((step - 2) * CODEBOOK_SIZE + codes[step - 2].coerceIn(0, CODEBOOK_SIZE - 1)) * TALKER_DIM + System.arraycopy(cembs, off, emb, 0, TALKER_DIM) + } + + // Build mask: last (step+1) positions active + val mask = FloatArray(CP_KV_LEN) { -1e9f } + for (p in 0..minOf(step, CP_KV_LEN - 1)) mask[CP_KV_LEN - 1 - p] = 0f + + val cosSlice = FloatArray(CP_HEAD_DIM) + System.arraycopy(cos, step * CP_HEAD_DIM, cosSlice, 0, CP_HEAD_DIM) + val sinSlice = FloatArray(CP_HEAD_DIM) + System.arraycopy(sin, step * CP_HEAD_DIM, sinSlice, 0, CP_HEAD_DIM) + + // Build EValue inputs + val inputs = arrayOf( + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(emb, longArrayOf(1, 1, TALKER_DIM.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(mask, longArrayOf(1, 1, 1, CP_KV_LEN.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(cosSlice, longArrayOf(1, 1, CP_HEAD_DIM.toLong()))), + org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(sinSlice, longArrayOf(1, 1, CP_HEAD_DIM.toLong()))), + ) + // Add KV caches + val allInputs = inputs.toMutableList() + for (i in 0 until CP_LAYERS) { + allInputs.add(org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(kCaches[i], longArrayOf(1, CP_KV_HEADS.toLong(), CP_KV_LEN.toLong(), CP_HEAD_DIM.toLong())))) + allInputs.add(org.pytorch.executorch.EValue.from( + org.pytorch.executorch.Tensor.fromBlob(vCaches[i], longArrayOf(1, CP_KV_HEADS.toLong(), CP_KV_LEN.toLong(), CP_HEAD_DIM.toLong())))) + } + + val outputs = module.forward(*allInputs.toTypedArray()) + + // .pte outputs: hidden[1,1,1024], k0[1,8,16,128], v0[1,8,16,128], ... + val hiddenOut = outputs[0].toTensor().dataAsFloatArray + + // Head argmax on CPU using cached heads + if (step >= 1 && step - 1 < 15) { + if (cpHeadsCache == null) cpHeadsCache = arrayOfNulls(15) + val cache = cpHeadsCache!! + val cbIdx = step - 1 + if (cache[cbIdx] == null) { + val hp = cpHeadsPath ?: return codes + cache[cbIdx] = loadNpy(hp.replace("cp_heads.npy", "head_${cbIdx}.npy")) + } + val headData = cache[cbIdx]!! + var best = 0; var bestVal = Float.NEGATIVE_INFINITY + for (j in 0 until CODEBOOK_SIZE) { + var dot = 0f + val off = j * TALKER_DIM + for (k in 0 until TALKER_DIM) dot += hiddenOut[k] * headData[off + k] + if (dot > bestVal) { bestVal = dot; best = j } + } + codes[cbIdx] = best + } + + // Update KV caches (output is [1,8,16,128] — fixed size, already shifted) + for (i in 0 until CP_LAYERS) { + kCaches[i] = outputs[1 + i * 2].toTensor().dataAsFloatArray + vCaches[i] = outputs[2 + i * 2].toTensor().dataAsFloatArray + } + } + } catch (e: Exception) { + nlog("CP .pte error: ${e.message}, falling back to ONNX CPU") + cpPteModule = null + return runCpV2(pastHidden, cb0) + } + + return codes + } + + /** CP V2: ONNX single-step with KV-cache, 15 lm_heads, autoregressive. */ + private fun runCpV2(pastHidden: FloatArray, cb0: Int): IntArray { + val env = ortEnv ?: return IntArray(15) + val session = cpKv ?: return IntArray(15) + val cos = cpRotaryCos ?: return IntArray(15) + val sin = cpRotarySin ?: return IntArray(15) + val headPath = cpHeadsPath ?: return IntArray(15) + val cembs = cpEmbeddings ?: return IntArray(15) + + val codes = IntArray(15) + // Fixed KV: size=CP_KV_LEN always, with mask for active positions + // Dynamic KV: starts empty, grows + val kvSize = if (cpFixedKv) CP_KV_HEADS * CP_KV_LEN * CP_HEAD_DIM else 0 + var kCaches = Array(CP_LAYERS) { FloatArray(kvSize) } + var vCaches = Array(CP_LAYERS) { FloatArray(kvSize) } + val cpMask = if (cpFixedKv) FloatArray(CP_KV_LEN) { -1e9f } else null + + // Step 0: hidden state + var emb = pastHidden + for (step in 0 until 17) { + if (step == 1) { + // cb0 embedding from talker codec_embedding + emb = FloatArray(TALKER_DIM) + System.arraycopy(codecEmbedding!!, cb0.coerceIn(0, TALKER_VOCAB - 1) * TALKER_DIM, emb, 0, TALKER_DIM) + } else if (step >= 2) { + // codec_embedding from CP's own tables + emb = FloatArray(TALKER_DIM) + val off = ((step - 2) * CODEBOOK_SIZE + codes[step - 2].coerceIn(0, CODEBOOK_SIZE - 1)) * TALKER_DIM + System.arraycopy(cembs, off, emb, 0, TALKER_DIM) + } + + val kvLen = if (cpFixedKv) CP_KV_LEN else kCaches[0].size / (CP_KV_HEADS * CP_HEAD_DIM) + val cosSlice = FloatArray(CP_HEAD_DIM) + System.arraycopy(cos, step * CP_HEAD_DIM, cosSlice, 0, CP_HEAD_DIM) + val sinSlice = FloatArray(CP_HEAD_DIM) + System.arraycopy(sin, step * CP_HEAD_DIM, sinSlice, 0, CP_HEAD_DIM) + + val inputs = LinkedHashMap() + inputs["emb"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(emb), longArrayOf(1, 1, TALKER_DIM.toLong())) + if (cpFixedKv) { + // Fixed KV mask: after step N, the last (N+1) positions are valid + // The model shifts left internally, so valid positions are right-aligned + val mask = FloatArray(CP_KV_LEN) { -1e9f } + for (p in 0..step) mask[CP_KV_LEN - 1 - p] = 0f + inputs["mask"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(mask), longArrayOf(1, 1, 1, CP_KV_LEN.toLong())) + } + inputs["cos"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(cosSlice), longArrayOf(1, CP_HEAD_DIM.toLong())) + inputs["sin"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(sinSlice), longArrayOf(1, CP_HEAD_DIM.toLong())) + for (i in 0 until CP_LAYERS) { + inputs["k_${i}_in"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(kCaches[i]), + longArrayOf(1, CP_KV_HEADS.toLong(), kvLen.toLong(), CP_HEAD_DIM.toLong())) + inputs["v_${i}_in"] = OnnxTensor.createTensor(env, FloatBuffer.wrap(vCaches[i]), + longArrayOf(1, CP_KV_HEADS.toLong(), kvLen.toLong(), CP_HEAD_DIM.toLong())) + } + + val result = session.run(inputs) + val hidden = FloatArray(TALKER_DIM) + (result.get(0) as OnnxTensor).floatBuffer.get(hidden) + + // Extract code from lm_head: hidden @ head[cb]^T → argmax + if (step >= 1 && step - 1 < 15) { + val cbIdx = step - 1 + // Lazy-load head into cache (8MB each, loaded once) + if (cpHeadsCache == null) cpHeadsCache = arrayOfNulls(15) + val cache = cpHeadsCache!! + if (cache[cbIdx] == null) { + cache[cbIdx] = loadNpy(headPath.replace("cp_heads.npy", "head_${cbIdx}.npy")) + } + val headData = cache[cbIdx]!! + var best = 0; var bestVal = Float.NEGATIVE_INFINITY + for (j in 0 until CODEBOOK_SIZE) { + var dot = 0f + val off = j * TALKER_DIM + for (k in 0 until TALKER_DIM) dot += hidden[k] * headData[off + k] + if (dot > bestVal) { bestVal = dot; best = j } + } + codes[cbIdx] = best + } + + // Update KV caches + if (cpFixedKv) { + // Fixed: output is same size as input (CP_KV) + val fixedSize = CP_KV_HEADS * CP_KV_LEN * CP_HEAD_DIM + kCaches = Array(CP_LAYERS) { i -> + FloatArray(fixedSize).also { (result.get(1 + i * 2) as OnnxTensor).floatBuffer.get(it) } + } + vCaches = Array(CP_LAYERS) { i -> + FloatArray(fixedSize).also { (result.get(2 + i * 2) as OnnxTensor).floatBuffer.get(it) } + } + } else { + // Dynamic: grows by 1 + val newKvLen = kvLen + 1 + val newKvSize = CP_KV_HEADS * newKvLen * CP_HEAD_DIM + kCaches = Array(CP_LAYERS) { i -> + FloatArray(newKvSize).also { (result.get(1 + i * 2) as OnnxTensor).floatBuffer.get(it) } + } + vCaches = Array(CP_LAYERS) { i -> + FloatArray(newKvSize).also { (result.get(2 + i * 2) as OnnxTensor).floatBuffer.get(it) } + } + } + + for ((_, v) in inputs) v.close() + result.close() + } + + return codes + } + + + /** CP via ONNX CPU KV-cache: 17 single-token steps with dynamic KV. ~180ms. */ + private fun runCpCpu(pastHidden: FloatArray, cb0: Int): IntArray { + val env = ortEnv ?: return IntArray(15) + val cpModel = cpKv ?: return IntArray(15) + val cpEmbs = cpEmbeddings ?: return IntArray(15) + + val codes = IntArray(15) + // Dynamic KV caches: grow from [1,8,0,128] to [1,8,16,128] + var kCaches = Array(CP_LAYERS) { FloatArray(0) } + var vCaches = Array(CP_LAYERS) { FloatArray(0) } + + // Step 0: hidden state + var emb = pastHidden + for (step in 0 until 17) { + if (step == 1) { + emb = FloatArray(TALKER_DIM) + System.arraycopy(codecEmbedding!!, cb0.coerceIn(0, TALKER_VOCAB - 1) * TALKER_DIM, emb, 0, TALKER_DIM) + } else if (step >= 2) { + emb = FloatArray(TALKER_DIM) + val off = ((step - 2) * CODEBOOK_SIZE + codes[step - 2].coerceIn(0, CODEBOOK_SIZE - 1)) * TALKER_DIM + System.arraycopy(cpEmbs, off, emb, 0, TALKER_DIM) + } + + val totalLen = step + 1 + val inputs = LinkedHashMap() + inputs["input_embeds"] = OnnxTensor.createTensor(env, + FloatBuffer.wrap(emb), longArrayOf(1, 1, TALKER_DIM.toLong())) + inputs["attention_mask"] = OnnxTensor.createTensor(env, + FloatBuffer.wrap(FloatArray(totalLen)), longArrayOf(1, 1, 1, totalLen.toLong())) + inputs["position_ids"] = OnnxTensor.createTensor(env, + LongBuffer.wrap(longArrayOf(step.toLong())), longArrayOf(1, 1)) + for (i in 0 until CP_LAYERS) { + val kvLen = kCaches[i].size / (CP_KV_HEADS * CP_HEAD_DIM) + inputs["k_${i}_in"] = OnnxTensor.createTensor(env, + FloatBuffer.wrap(kCaches[i]), longArrayOf(1, CP_KV_HEADS.toLong(), kvLen.toLong(), CP_HEAD_DIM.toLong())) + inputs["v_${i}_in"] = OnnxTensor.createTensor(env, + FloatBuffer.wrap(vCaches[i]), longArrayOf(1, CP_KV_HEADS.toLong(), kvLen.toLong(), CP_HEAD_DIM.toLong())) + } + + val result = cpModel.run(inputs) + + // Extract head logits [1, 15, 2048] and code for this step + if (step >= 1 && step - 1 < 15) { + val headLogits = FloatArray(15 * CODEBOOK_SIZE) + (result.get(1) as OnnxTensor).floatBuffer.get(headLogits) + val cbIdx = step - 1 + val headOff = cbIdx * CODEBOOK_SIZE + var maxIdx = 0; var maxVal = Float.NEGATIVE_INFINITY + for (j in 0 until CODEBOOK_SIZE) { + if (headLogits[headOff + j] > maxVal) { maxVal = headLogits[headOff + j]; maxIdx = j } + } + codes[cbIdx] = maxIdx + } + + // Update KV caches (dynamic size grows by 1 each step) + val newKvSize = CP_KV_HEADS * totalLen * CP_HEAD_DIM + kCaches = Array(CP_LAYERS) { i -> + FloatArray(newKvSize).also { (result.get(2 + i * 2) as OnnxTensor).floatBuffer.get(it) } + } + vCaches = Array(CP_LAYERS) { i -> + FloatArray(newKvSize).also { (result.get(3 + i * 2) as OnnxTensor).floatBuffer.get(it) } + } + + for ((_, v) in inputs) v.close() + result.close() + } + + return codes + } + + /** + * Trim trailing silence/noise from audio. + * Scans backward in 80ms windows. When RMS exceeds threshold, adds 200ms margin and fades out. + */ + private fun trimTrailingSilence(audio: ShortArray): ShortArray { + val windowSize = SR * 80 / 1000 // 80ms window + val marginSamples = SR * 200 / 1000 // 200ms margin after last activity + val fadeSamples = SR * 100 / 1000 // 100ms fade-out + + // Compute RMS of the first second as reference for "speech energy" + val refSamples = minOf(SR, audio.size) + var refEnergy = 0.0 + for (i in 0 until refSamples) refEnergy += audio[i].toDouble() * audio[i] + val refRms = kotlin.math.sqrt(refEnergy / refSamples) + val threshold = refRms * 0.05 // 5% of reference = silence + + // Scan backward in windows to find last speech + var lastSpeechEnd = audio.size + var pos = audio.size - windowSize + while (pos >= 0) { + var energy = 0.0 + for (i in pos until minOf(pos + windowSize, audio.size)) { + energy += audio[i].toDouble() * audio[i] + } + val rms = kotlin.math.sqrt(energy / windowSize) + if (rms > threshold) { + lastSpeechEnd = pos + windowSize + break + } + pos -= windowSize + } + + val trimEnd = minOf(lastSpeechEnd + marginSamples, audio.size) + val result = audio.copyOf(trimEnd) + + // Apply fade-out + val fadeStart = maxOf(0, result.size - fadeSamples) + for (i in fadeStart until result.size) { + val alpha = 1f - (i - fadeStart).toFloat() / (result.size - fadeStart) + result[i] = (result[i] * alpha).toInt().toShort() + } + return result + } + + /** Sample from logits with temperature scaling and top-K filtering */ + private fun sampleTopK(logits: FloatArray, temperature: Float = 0.9f, topK: Int = 50): Int { + // Find top-K indices + val indices = logits.indices.sortedByDescending { logits[it] }.take(topK) + + // Temperature-scaled softmax over top-K + val maxLogit = logits[indices[0]] + val expValues = FloatArray(indices.size) + var sumExp = 0f + for (i in indices.indices) { + val scaled = (logits[indices[i]] - maxLogit) / temperature + expValues[i] = kotlin.math.exp(scaled) + sumExp += expValues[i] + } + + // Categorical sampling + val r = Math.random().toFloat() * sumExp + var cumSum = 0f + for (i in indices.indices) { + cumSum += expValues[i] + if (cumSum >= r) return indices[i] + } + return indices.last() + } + + // ==================== Code Predictor ==================== + + // ==================== Speech Decoder ==================== + + /** Decode 16 codebooks to audio in chunks, with overlap for seamless concatenation */ + private fun decodeChunked(codebooks: Array, numRealTokens: Int): ShortArray { + val totalTokens = codebooks[0].size + + if (totalTokens <= SEQ_LEN) { + val quantized = vqDecode(codebooks) + val fullAudio = runSpeechDecoder(quantized) + val trimSamples = minOf(numRealTokens * SAMPLES_PER_TOKEN, fullAudio.size) + return fullAudio.copyOf(trimSamples) + } + + val overlapSamples = CHUNK_OVERLAP * SAMPLES_PER_TOKEN + var result = ShortArray(0) + var pos = 0 + + while (pos < numRealTokens) { + val chunkCodes = Array(NUM_CODEBOOKS) { cb -> + IntArray(SEQ_LEN) { t -> + val srcIdx = pos + t + if (srcIdx < totalTokens) codebooks[cb][srcIdx] else 0 + } + } + + val quantized = vqDecode(chunkCodes) + val chunkAudio = runSpeechDecoder(quantized) + + // Trim chunk to real tokens + val realInChunk = minOf(SEQ_LEN, numRealTokens - pos) + val trimmed = chunkAudio.copyOf(minOf(realInChunk * SAMPLES_PER_TOKEN, chunkAudio.size)) + + if (pos == 0) { + result = trimmed + } else { + // Crossfade over the overlap region + val fadeLen = minOf(overlapSamples, result.size, trimmed.size) + for (i in 0 until fadeLen) { + val alpha = i.toFloat() / fadeLen + val mixed = ((1f - alpha) * result[result.size - fadeLen + i] + alpha * trimmed[i]).toInt() + .coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() + result[result.size - fadeLen + i] = mixed + } + // Append the non-overlapping part + if (fadeLen < trimmed.size) { + val newPart = trimmed.copyOfRange(fadeLen, trimmed.size) + val combined = ShortArray(result.size + newPart.size) + System.arraycopy(result, 0, combined, 0, result.size) + System.arraycopy(newPart, 0, combined, result.size, newPart.size) + result = combined + } + } + + pos += EFFECTIVE_CHUNK + } + return result + } + + /** VQ decode: 16 codebooks → quantized [1, 512, SEQ_LEN] */ + private fun vqDecode(codebooks: Array): FloatArray { + val firstCb = firstCodebook ?: return FloatArray(0) + val restCbs = restCodebooks ?: return FloatArray(0) + val firstProj = firstOutputProj ?: return FloatArray(0) + val restProj = restOutputProj ?: return FloatArray(0) + + val qFirst = FloatArray(CODEBOOK_DIM * SEQ_LEN) + for (t in 0 until SEQ_LEN) { + val idx = codebooks[0][t] + System.arraycopy(firstCb, idx * CODEBOOK_DIM, qFirst, t * CODEBOOK_DIM, CODEBOOK_DIM) + } + val quantized = FloatArray(HIDDEN_DIM * SEQ_LEN) + for (i in 0 until HIDDEN_DIM) { + for (t in 0 until SEQ_LEN) { + var sum = 0f + for (d in 0 until CODEBOOK_DIM) { + sum += firstProj[i * CODEBOOK_DIM + d] * qFirst[t * CODEBOOK_DIM + d] + } + quantized[i * SEQ_LEN + t] = sum + } + } + + val restSum = FloatArray(CODEBOOK_DIM * SEQ_LEN) + for (cb in 0 until 15) { + val cbData = restCbs[cb] + for (t in 0 until SEQ_LEN) { + val idx = codebooks[cb + 1][t] + for (d in 0 until CODEBOOK_DIM) { + restSum[t * CODEBOOK_DIM + d] += cbData[idx * CODEBOOK_DIM + d] + } + } + } + for (i in 0 until HIDDEN_DIM) { + for (t in 0 until SEQ_LEN) { + var sum = 0f + for (d in 0 until CODEBOOK_DIM) { + sum += restProj[i * CODEBOOK_DIM + d] * restSum[t * CODEBOOK_DIM + d] + } + quantized[i * SEQ_LEN + t] += sum + } + } + + return quantized + } + + /** Run speech decoder: quantized → pre_conv → preprocessor → ConvNet → audio */ + private fun runSpeechDecoder(quantized: FloatArray): ShortArray { + val env = ortEnv!! + if (decoderOnCpu || decoderOnGpu) { + return runSpeechDecoderV2(quantized) + } + val qTensor = OnnxTensor.createTensor( + env, FloatBuffer.wrap(quantized), longArrayOf(1, HIDDEN_DIM.toLong(), SEQ_LEN.toLong()) + ) + val pcResult = preConv!!.run(mapOf("x" to qTensor)) + val pcOut = pcResult[0] as OnnxTensor + nlog("pre_conv: ${qTensor.info.shape.contentToString()} → ${pcOut.info.shape.contentToString()}") + qTensor.close() + + val ppResult = preprocessor!!.run(mapOf("pre_conv_out" to pcOut)) + val ppOut = ppResult[0] as OnnxTensor + nlog("preprocessor: → ${ppOut.info.shape.contentToString()}") + pcResult.close() + + val cdResult = convDecoder!!.run(mapOf("hidden" to ppOut)) + val cdOut = cdResult[0] as OnnxTensor + nlog("conv_decoder: → ${cdOut.info.shape.contentToString()}") + @Suppress("UNCHECKED_CAST") + val audioFloat = (cdOut.value as Array>)[0][0] + ppResult.close() + cdResult.close() + + return ShortArray(audioFloat.size) { + (audioFloat[it].coerceIn(-1f, 1f) * 32767).toInt().toShort() + } + } + + /** V2 decoder: quantized[1,512,60] → pre_conv → transpose → pre_transformer → decoder → audio */ + private fun runSpeechDecoderV2(quantized: FloatArray): ShortArray { + val env = ortEnv!! + // pre_conv: [1,512,60] → [1,1024,60] + val qTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(quantized), + longArrayOf(1, HIDDEN_DIM.toLong(), SEQ_LEN.toLong())) + val pcResult = preConv!!.run(mapOf(preConv!!.inputNames.first() to qTensor)) + val pcOutRaw = (pcResult[0] as OnnxTensor).floatBuffer + val pcData = FloatArray(1024 * SEQ_LEN) + pcOutRaw.get(pcData) + nlog("V2 pre_conv done") + qTensor.close() + + // Transpose [1,1024,60] → [1,60,1024] + val transposed = FloatArray(SEQ_LEN * 1024) + for (c in 0 until 1024) { + for (t in 0 until SEQ_LEN) { + transposed[t * 1024 + c] = pcData[c * SEQ_LEN + t] + } + } + val ptInput = OnnxTensor.createTensor(env, FloatBuffer.wrap(transposed), + longArrayOf(1, SEQ_LEN.toLong(), 1024)) + pcResult.close() + + // pre_transformer: [1,60,1024] → [1,60,1024] + val ptResult = preprocessor!!.run(mapOf(preprocessor!!.inputNames.first() to ptInput)) + val ptOut = ptResult[0] as OnnxTensor + nlog("V2 pre_transformer done") + ptInput.close() + + // decoder: [1,60,1024] → [1,1,samples] + val cdResult = convDecoder!!.run(mapOf(convDecoder!!.inputNames.first() to ptOut)) + val cdOut = cdResult[0] as OnnxTensor + nlog("V2 decoder done: ${cdOut.info.shape.contentToString()}") + @Suppress("UNCHECKED_CAST") + val audioFloat = (cdOut.value as Array>)[0][0] + ptResult.close() + cdResult.close() + + return ShortArray(audioFloat.size) { + (audioFloat[it].coerceIn(-1f, 1f) * 32767).toInt().toShort() + } + } + + // ==================== Loading ==================== + + private fun loadVqCodebooks(path: String) { + firstCodebook = loadNpy("$path/vq_rvq_first_vq_layers_0_codebook.npy") + firstOutputProj = loadNpy("$path/vq_rvq_first_output_proj_w.npy") + restOutputProj = loadNpy("$path/vq_rvq_rest_output_proj_w.npy") + restCodebooks = Array(15) { i -> + loadNpy("$path/vq_rvq_rest_vq_layers_${i}_codebook.npy") + } + nlog("VQ codebooks loaded (16 × [${CODEBOOK_SIZE}, ${CODEBOOK_DIM}])") + } + + /** Load a numpy .npy file as float array */ + private fun loadNpy(path: String): FloatArray { + val file = File(path) + if (!file.exists()) { + nlog("WARN: $path not found") + return FloatArray(0) + } + val bytes = file.readBytes() + val headerLen = (bytes[8].toInt() and 0xFF) or ((bytes[9].toInt() and 0xFF) shl 8) + val dataOffset = 10 + headerLen + val numFloats = (bytes.size - dataOffset) / 4 + val result = FloatArray(numFloats) + val bb = ByteBuffer.wrap(bytes, dataOffset, bytes.size - dataOffset) + .order(ByteOrder.LITTLE_ENDIAN) + bb.asFloatBuffer().get(result) + return result + } + + // ==================== Audio Playback ==================== + + private fun playAudio(audioData: ShortArray, 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(SR) + .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() + } + } + + /** + * Full pipeline from pre-computed embeddings (from Python generate() capture). + * File format: int32 n_prefill, int32 n_total, then n_total × 1024 floats. + * Runs talker ONNX → CP → VQ decode → speech decoder. + */ + fun generateFromEmbeds(embedsPath: String): ShortArray { + if (!loaded || (talkerPteModule == null && !useHexagonTalker && (talkerKv == null || !talkerUsesCosSin))) { + nlog("generateFromEmbeds: talker not loaded (pte=${talkerPteModule != null}, hex=$useHexagonTalker, onnx=${talkerKv != null})") + return ShortArray(0) + } + if (useHexagonTalker) { + return generateFromEmbedsHexagon(embedsPath) + } + if (talkerPteModule != null && cpPteModule != null) { + return generateFromEmbedsPte(embedsPath) + } + nlog("Full pipeline from: $embedsPath") + val t0 = System.currentTimeMillis() + + val bytes = File(embedsPath).readBytes() + val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val nPrefill = bb.int + val nTotal = bb.int + val embeds = Array(nTotal) { FloatArray(TALKER_DIM).also { arr -> for (j in 0 until TALKER_DIM) arr[j] = bb.float } } + nlog("Loaded $nTotal embeds ($nPrefill prefill + ${nTotal - nPrefill} decode)") + + val env = ortEnv!! + val session = talkerKv!! + val cos = rotaryCos!! + val sin = rotarySin!! + + // KV caches [1, 8, KV_LEN, 128] + val kvSize = TALKER_HEADS * KV_LEN * TALKER_HEAD_DIM + var kCaches = Array(TALKER_LAYERS) { FloatArray(kvSize) } + var vCaches = Array(TALKER_LAYERS) { FloatArray(kvSize) } + val maskData = FloatArray(MAX_CONTEXT) { -1e9f } + + val allCodes = mutableListOf() + val generatedCb0 = mutableListOf() + var currentCb0 = -1 + var pastHidden: FloatArray? = null + + // Prefill + val tPrefill = System.currentTimeMillis() + for (step in 0 until nPrefill) { + maskData[MAX_CONTEXT - 1 - step] = 0f + val res = runTalkerStepMRoPE(env, session, embeds[step], maskData, step, kCaches, vCaches) + kCaches = res.newK; vCaches = res.newV; pastHidden = res.hidden + + if (step == nPrefill - 1) { + val logits = res.logits + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + currentCb0 = sampleTopK(logits, 0.9f, 50) + nlog("Prefill: ${System.currentTimeMillis() - tPrefill}ms, cb0=$currentCb0") + } + } + if (currentCb0 < 0 || currentCb0 == CODEC_EOS) return ShortArray(0) + + // Generation using captured decode embeddings + var totalTalkerMs = 0L; var totalCpMs = 0L + for (genStep in 0 until (nTotal - nPrefill)) { + val codes = IntArray(NUM_CODEBOOKS) + codes[0] = currentCb0 + + val tCp = System.currentTimeMillis() + val cpCodes = runCodePredictorInterleaved(pastHidden!!, currentCb0) + totalCpMs += System.currentTimeMillis() - tCp + for (cb in 1 until NUM_CODEBOOKS) codes[cb] = cpCodes[cb - 1] + allCodes.add(codes) + generatedCb0.add(currentCb0) + + if (genStep < 3) nlog("Step ${genStep+1}: cb0=$currentCb0 cb1=${codes[1]}") + + // Use captured embedding for next step + val nextEmbed = embeds[nPrefill + genStep] + maskData[MAX_CONTEXT - 1 - (nPrefill + genStep)] = 0f + + val tTalker = System.currentTimeMillis() + val res = runTalkerStepMRoPE(env, session, nextEmbed, maskData, nPrefill + genStep, kCaches, vCaches) + totalTalkerMs += System.currentTimeMillis() - tTalker + kCaches = res.newK; vCaches = res.newV; pastHidden = res.hidden + + val logits = res.logits + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + val seen = HashSet(); for (prev in generatedCb0) seen.add(prev) + for (tok in seen) { logits[tok] = if (logits[tok] > 0) logits[tok] / 1.05f else logits[tok] * 1.05f } + val nextCb0 = sampleTopK(logits, 0.9f, 50) + + if (nextCb0 == CODEC_EOS) { nlog("EOS at step ${genStep + 2}"); break } + if (generatedCb0.size >= 9 && generatedCb0.takeLast(9).all { it == nextCb0 }) { + nlog("Degeneration at step ${genStep + 2}"); break + } + currentCb0 = nextCb0 + } + + val n = allCodes.size + nlog("Generated $n tokens | Talker: ${totalTalkerMs}ms (${totalTalkerMs/maxOf(n,1)}ms/step) | CP: ${totalCpMs}ms (${totalCpMs/maxOf(n,1)}ms/step)") + + if (n == 0) return ShortArray(0) + + // Decode + val padLen = maxOf(n, SEQ_LEN) + val allCodebooks = Array(NUM_CODEBOOKS) { cb -> + IntArray(padLen) { t -> if (t < n) allCodes[t][cb] else 0 } + } + val audio = decodeChunked(allCodebooks, n) + nlog("Total: ${System.currentTimeMillis() - t0}ms for ${audio.size.toFloat()/SR}s audio") + return audio + } + + /** Full pipeline using .pte JNI talker + CP on NPU from pre-computed embeddings. */ + private fun generateFromEmbedsPte(embedsPath: String): ShortArray { + nlog("Full pipeline (PTE) from: $embedsPath") + val t0 = System.currentTimeMillis() + val bytes = File(embedsPath).readBytes() + val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val nPrefill = bb.int; val nTotal = bb.int + val embeds = Array(nTotal) { FloatArray(TALKER_DIM).also { arr -> for (j in 0 until TALKER_DIM) arr[j] = bb.float } } + nlog("Loaded $nTotal embeds ($nPrefill prefill + ${nTotal - nPrefill} decode)") + + // Build textEmbedsList: first nPrefill are "prefill", rest are trailing + val textEmbedsList = embeds.toList() + val allCodesArray = runInterleavedPte(textEmbedsList.subList(0, 1), maxGenTokens = nTotal - nPrefill) + // Note: runInterleavedPte handles prefill internally via buildPrefillEmbeddings + + // Actually, generateFromEmbeds uses pre-computed embeds directly. + // Let's use runInterleavedPte properly by passing all embeds as textEmbedsList + // runInterleavedPte's buildPrefillEmbeddings will create the prefill from first embed + // But we need a different approach: pass all embeds directly. + + // Simpler: use the run_pipeline code path which goes through generateSpeech → runInterleavedGeneration + // For now, let's just call runInterleavedPte with the right embeds structure + val prefillEmbeds = embeds.sliceArray(0 until nPrefill).toList() + val trailingEmbeds = if (nPrefill < nTotal) embeds.sliceArray(nPrefill until nTotal).toList() else emptyList() + + // Call the PTE pipeline directly + val allCodes = runInterleavedPteFromEmbeds(prefillEmbeds, trailingEmbeds, nTotal - nPrefill) + + if (allCodes.isEmpty()) return ShortArray(0) + val numRealTokens = allCodes.size + val padLen = maxOf(numRealTokens, SEQ_LEN) + val allCodebooks = Array(NUM_CODEBOOKS) { cb -> + IntArray(padLen) { t -> if (t < numRealTokens) allCodes[t][cb] else 0 } + } + + val t3 = System.currentTimeMillis() + val audio = decodeChunked(allCodebooks, numRealTokens) + nlog("Decode: ${System.currentTimeMillis() - t3}ms") + + val totalMs = System.currentTimeMillis() - t0 + val audioDur = audio.size.toFloat() / SR + nlog("Total: ${totalMs}ms for ${audioDur}s") + + // Save WAV file for validation + try { + val wavPath = "/data/local/tmp/kazeia/kazeia_PTE_NPU.wav" + val fos = java.io.FileOutputStream(wavPath) + val dataLen = audio.size * 2 + val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN) + header.put("RIFF".toByteArray()); header.putInt(36 + dataLen) + header.put("WAVE".toByteArray()); header.put("fmt ".toByteArray()) + header.putInt(16); header.putShort(1); header.putShort(1) + header.putInt(SR); header.putInt(SR * 2); header.putShort(2); header.putShort(16) + header.put("data".toByteArray()); header.putInt(dataLen) + fos.write(header.array()) + val buf = ByteBuffer.allocate(dataLen).order(ByteOrder.LITTLE_ENDIAN) + for (s in audio) buf.putShort(s) + fos.write(buf.array()); fos.close() + nlog("WAV saved: $wavPath (${audio.size} samples)") + } catch (e: Exception) { + nlog("WAV save failed: ${e.message}") + } + + return audio + } + + /** PTE pipeline from pre-computed embeddings (prefill + trailing). */ + private fun runInterleavedPteFromEmbeds( + prefillEmbeds: List, trailingEmbeds: List, maxGenTokens: Int + ): Array { + val talkerMod = talkerPteModule ?: return emptyArray() + val cpMod = cpPteModule ?: return emptyArray() + val tCos = talkerPteRotaryCos ?: return emptyArray() + val tSin = talkerPteRotarySin ?: return emptyArray() + val eosE = ttsEosEmbed ?: return emptyArray() + val padE = ttsPadEmbed ?: return emptyArray() + + val allCodes = mutableListOf() + val generatedCb0 = mutableListOf() + + val tkvSize = TALKER_HEADS * TALKER_PTE_KV_LEN * TALKER_HEAD_DIM + var tK = Array(TALKER_LAYERS) { FloatArray(tkvSize) } + var tV = Array(TALKER_LAYERS) { FloatArray(tkvSize) } + val maskData = FloatArray(TALKER_PTE_KV_LEN) { -1e9f } + + var pos = 0; var currentCb0 = -1; var pastHidden: FloatArray? = null + var trailingIdx = 0 + + // Helper to run one talker step + fun talkerStep(emb: FloatArray): Pair { + val maskIdx = TALKER_PTE_KV_LEN - 1 - minOf(pos, TALKER_PTE_KV_LEN - 1) + if (maskIdx >= 0) maskData[maskIdx] = 0f + + val posIdx = minOf(pos, tCos.size / TALKER_HEAD_DIM - 1) + val cosSlice = FloatArray(TALKER_HEAD_DIM); System.arraycopy(tCos, posIdx * TALKER_HEAD_DIM, cosSlice, 0, TALKER_HEAD_DIM) + val sinSlice = FloatArray(TALKER_HEAD_DIM); System.arraycopy(tSin, posIdx * TALKER_HEAD_DIM, sinSlice, 0, TALKER_HEAD_DIM) + + val inputs = mutableListOf( + org.pytorch.executorch.EValue.from(org.pytorch.executorch.Tensor.fromBlob(emb, longArrayOf(1, 1, TALKER_DIM.toLong()))), + org.pytorch.executorch.EValue.from(org.pytorch.executorch.Tensor.fromBlob(maskData.clone(), longArrayOf(1, 1, 1, TALKER_PTE_KV_LEN.toLong()))), + org.pytorch.executorch.EValue.from(org.pytorch.executorch.Tensor.fromBlob(cosSlice, longArrayOf(1, 1, TALKER_HEAD_DIM.toLong()))), + org.pytorch.executorch.EValue.from(org.pytorch.executorch.Tensor.fromBlob(sinSlice, longArrayOf(1, 1, TALKER_HEAD_DIM.toLong()))) + ) + for (i in 0 until TALKER_LAYERS) { + inputs.add(org.pytorch.executorch.EValue.from(org.pytorch.executorch.Tensor.fromBlob(tK[i], longArrayOf(1, TALKER_HEADS.toLong(), TALKER_PTE_KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())))) + inputs.add(org.pytorch.executorch.EValue.from(org.pytorch.executorch.Tensor.fromBlob(tV[i], longArrayOf(1, TALKER_HEADS.toLong(), TALKER_PTE_KV_LEN.toLong(), TALKER_HEAD_DIM.toLong())))) + } + + val out = talkerMod.forward(*inputs.toTypedArray()) + val hidden = out[0].toTensor().dataAsFloatArray + val logits = out[1].toTensor().dataAsFloatArray + for (i in 0 until TALKER_LAYERS) { + tK[i] = out[2 + i * 2].toTensor().dataAsFloatArray + tV[i] = out[3 + i * 2].toTensor().dataAsFloatArray + } + pos++ + return Pair(hidden, logits) + } + + // ===== PREFILL ===== + val tPrefill = System.currentTimeMillis() + for (step in prefillEmbeds.indices) { + val (h, logits) = talkerStep(prefillEmbeds[step]) + pastHidden = h + if (step == prefillEmbeds.size - 1) { + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + currentCb0 = sampleTopK(logits, 0.9f, 50) + } + } + nlog("Prefill (PTE): ${System.currentTimeMillis() - tPrefill}ms, ${prefillEmbeds.size} steps, cb0=$currentCb0") + if (currentCb0 < 0 || currentCb0 == CODEC_EOS) return emptyArray() + + // ===== GENERATION ===== + var totalTalkerMs = 0L; var totalCpMs = 0L + for (genStep in 0 until maxGenTokens) { + val codes = IntArray(NUM_CODEBOOKS); codes[0] = currentCb0 + + val tCp0 = System.currentTimeMillis() + val cpCodes = runCpPte(pastHidden!!, currentCb0) + totalCpMs += System.currentTimeMillis() - tCp0 + for (cb in 1 until NUM_CODEBOOKS) codes[cb] = cpCodes[cb - 1] + allCodes.add(codes); generatedCb0.add(currentCb0) + + if (genStep < 3) nlog("Step ${genStep+1}: cb0=$currentCb0 cb1=${codes[1]}") + + // Next talker input: use pre-computed decode embed if available + val nextEmbed: FloatArray + if (trailingIdx < trailingEmbeds.size) { + nextEmbed = trailingEmbeds[trailingIdx]; trailingIdx++ + } else { + // Build from codec embeddings + trailing text (pad) + val codecSum = FloatArray(TALKER_DIM) + addEmb(codecSum, codecEmb(codes[0])) + for (cb in 1 until NUM_CODEBOOKS) addEmb(codecSum, cpEmb(cb - 1, codes[cb])) + nextEmbed = sumEmb(codecSum, padE) + } + + val tTalker0 = System.currentTimeMillis() + val (h, logits) = talkerStep(nextEmbed) + totalTalkerMs += System.currentTimeMillis() - tTalker0 + pastHidden = h + + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + val seen = HashSet(); for (prev in generatedCb0) seen.add(prev) + for (tok in seen) { logits[tok] = if (logits[tok] > 0) logits[tok] / 1.05f else logits[tok] * 1.05f } + val nextCb0 = sampleTopK(logits, 0.9f, 50) + + if (nextCb0 == CODEC_EOS) { nlog("EOS at step ${genStep + 2}"); break } + if (generatedCb0.size >= 9 && generatedCb0.takeLast(9).all { it == nextCb0 }) { + nlog("Degeneration at step ${genStep + 2}"); break + } + currentCb0 = nextCb0 + } + + val n = allCodes.size + nlog("Generated $n tokens | Talker(PTE): ${totalTalkerMs}ms (${totalTalkerMs/maxOf(n,1)}ms/step) | CP(PTE): ${totalCpMs}ms (${totalCpMs/maxOf(n,1)}ms/step)") + return allCodes.toTypedArray() + } + + /** Full pipeline using Hexagon talker + Hexagon CP from pre-computed embeddings. */ + private fun generateFromEmbedsHexagon(embedsPath: String): ShortArray { + nlog("Full pipeline (Hexagon) from: $embedsPath") + val t0 = System.currentTimeMillis() + + val bytes = File(embedsPath).readBytes() + val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val nPrefill = bb.int; val nTotal = bb.int + val embeds = Array(nTotal) { FloatArray(TALKER_DIM).also { arr -> for (j in 0 until TALKER_DIM) arr[j] = bb.float } } + nlog("Loaded $nTotal embeds ($nPrefill prefill + ${nTotal - nPrefill} decode)") + + hexReset() + + val allCodes = mutableListOf() + val generatedCb0 = mutableListOf() + var totalTalkerMs = 0L; var totalCpMs = 0L + + // Prefill + val tPrefill = System.currentTimeMillis() + val prefillResults = hexForward(embeds.take(nPrefill)) + nlog("Prefill (Hex): ${System.currentTimeMillis() - tPrefill}ms, ${prefillResults.size} steps") + + if (prefillResults.isEmpty()) return ShortArray(0) + var pastHidden = prefillResults.last().first + val prefillLogits = prefillResults.last().second + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) prefillLogits[j] = Float.NEGATIVE_INFINITY } + var currentCb0 = sampleTopK(prefillLogits, 0.9f, 50) + nlog("Prefill cb0=$currentCb0") + + // Load trailing text embeddings (pre-computed from correct token IDs) + val trailingFile = File(embedsPath.replace("full_pipeline_embeds.bin", "trailing_text_embeds.bin")) + val trailingEmbeds = mutableListOf() + var correctEosE = ttsEosEmbed ?: FloatArray(TALKER_DIM) + var correctPadE = ttsPadEmbed ?: FloatArray(TALKER_DIM) + if (trailingFile.exists()) { + val tb = ByteBuffer.wrap(trailingFile.readBytes()).order(ByteOrder.LITTLE_ENDIAN) + val nTrailing = tb.int + for (i in 0 until nTrailing) { + trailingEmbeds.add(FloatArray(TALKER_DIM).also { arr -> for (j in 0 until TALKER_DIM) arr[j] = tb.float }) + } + correctEosE = FloatArray(TALKER_DIM).also { arr -> for (j in 0 until TALKER_DIM) arr[j] = tb.float } + correctPadE = FloatArray(TALKER_DIM).also { arr -> for (j in 0 until TALKER_DIM) arr[j] = tb.float } + nlog("Trailing text: ${trailingEmbeds.size} tokens + eos + pad") + } + var trailingIdx = 0 + + // Generation — build embeddings from ACTUAL codes (autonomous, no capture dependency) + for (genStep in 0 until (nTotal - nPrefill)) { + val codes = IntArray(NUM_CODEBOOKS); codes[0] = currentCb0 + val tCp = System.currentTimeMillis() + val cpCodes = runCodePredictorInterleaved(pastHidden, currentCb0) + totalCpMs += System.currentTimeMillis() - tCp + for (cb in 1 until NUM_CODEBOOKS) codes[cb] = cpCodes[cb - 1] + allCodes.add(codes); generatedCb0.add(currentCb0) + + if (genStep < 3) nlog("Step ${genStep+1}: cb0=$currentCb0 cb1=${codes[1]}") + + // Build next embedding from ACTUAL codes + correct trailing text + val codecSum = FloatArray(TALKER_DIM) + addEmb(codecSum, codecEmb(codes[0])) + for (cb in 1 until NUM_CODEBOOKS) addEmb(codecSum, cpEmb(cb - 1, codes[cb])) + + val textE: FloatArray = if (trailingIdx < trailingEmbeds.size) { + trailingEmbeds[trailingIdx++] + } else if (trailingIdx == trailingEmbeds.size) { + trailingIdx++; correctEosE + } else { + correctPadE + } + val nextEmbed = sumEmb(codecSum, textE) + + val tT = System.currentTimeMillis() + val results = hexForward(listOf(nextEmbed)) + totalTalkerMs += System.currentTimeMillis() - tT + if (results.isEmpty()) break + pastHidden = results[0].first + val logits = results[0].second + for (j in CODEBOOK_SIZE until TALKER_VOCAB) { if (j != CODEC_EOS) logits[j] = Float.NEGATIVE_INFINITY } + val seen = HashSet(); for (prev in generatedCb0) seen.add(prev) + for (tok in seen) { logits[tok] = if (logits[tok] > 0) logits[tok] / 1.05f else logits[tok] * 1.05f } + val nextCb0 = sampleTopK(logits, 0.9f, 50) + + if (nextCb0 == CODEC_EOS) { nlog("EOS at step ${genStep + 2}"); break } + if (generatedCb0.size >= 9 && generatedCb0.takeLast(9).all { it == nextCb0 }) { + nlog("Degeneration at step ${genStep + 2}"); break + } + currentCb0 = nextCb0 + } + + val n = allCodes.size + nlog("Generated $n tokens | Talker(HEX): ${totalTalkerMs}ms (${totalTalkerMs/maxOf(n,1)}ms/step) | CP: ${totalCpMs}ms (${totalCpMs/maxOf(n,1)}ms/step)") + + // Stop hexagon runners before decode ONLY if decoder uses HTP (DSP conflict) + if (!decoderOnCpu && !decoderOnGpu) { + hexStopRunner() + } + + if (n == 0) return ShortArray(0) + val padLen = maxOf(n, SEQ_LEN) + val allCodebooks = Array(NUM_CODEBOOKS) { cb -> IntArray(padLen) { t -> if (t < n) allCodes[t][cb] else 0 } } + val audio = decodeChunked(allCodebooks, n) + nlog("Total: ${System.currentTimeMillis() - t0}ms for ${audio.size.toFloat()/SR}s") + return audio + } + + /** Test with pre-computed codec tokens from PC (for validation) */ + fun testWithPrecomputedCodes(codesPath: String, realTokens: Int = 16): ShortArray { + if (!loaded) return ShortArray(0) + nlog("Testing with pre-computed codes: $codesPath (realTokens=$realTokens)") + try { + val bytes = File(codesPath).readBytes() + val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val allCodes = Array(NUM_CODEBOOKS) { IntArray(SEQ_LEN) } + for (cb in 0 until NUM_CODEBOOKS) { + for (t in 0 until SEQ_LEN) { + allCodes[cb][t] = bb.int.coerceIn(0, CODEBOOK_SIZE - 1) + } + } + nlog("Loaded ${NUM_CODEBOOKS} × ${SEQ_LEN} codes") + nlog("codes[0][:5] = ${allCodes[0].take(5).toList()}") + nlog("codes[1][:5] = ${allCodes[1].take(5).toList()}") + val t0 = System.currentTimeMillis() + val quantized = vqDecode(allCodes) + // Dump quantized stats for debug + var qMin = Float.MAX_VALUE; var qMax = Float.MIN_VALUE; var qSum = 0.0 + for (v in quantized) { qMin = minOf(qMin, v); qMax = maxOf(qMax, v); qSum += v * v } + nlog("quantized: size=${quantized.size}, range=[${qMin}, ${qMax}], rms=${Math.sqrt(qSum / quantized.size)}") + // Save quantized to file for comparison + try { + val qf = java.io.File("/data/local/tmp/kazeia/quantized_dump.bin") + qf.writeBytes(java.nio.ByteBuffer.allocate(quantized.size * 4).order(java.nio.ByteOrder.LITTLE_ENDIAN).also { + for (v in quantized) it.putFloat(v) + }.array()) + nlog("Saved quantized to ${qf.absolutePath}") + } catch (_: Exception) {} + val fullAudio = runSpeechDecoder(quantized) + val trimSamples = minOf(realTokens * SAMPLES_PER_TOKEN, fullAudio.size) + val audio = fullAudio.copyOf(trimSamples) + nlog("Decode: ${System.currentTimeMillis() - t0}ms, audio=${audio.size.toFloat()/SR}s (trimmed from ${fullAudio.size.toFloat()/SR}s)") + return audio + } catch (e: Exception) { + nlog("Test error: ${e.message}") + return ShortArray(0) + } + } + + override fun release() { + stop() + // Stop hexagon runners cleanly + if (useHexagonTalker || useHexagonCp) { + hexStopRunner() + } + talkerKv?.close() + cpKv?.close() + preConv?.close(); preprocessor?.close(); convDecoder?.close() + ortEnv?.close() + loaded = false + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/AudioTest.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/AudioTest.kt new file mode 100644 index 0000000..1a0f5a3 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/AudioTest.kt @@ -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() + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt new file mode 100644 index 0000000..2d34bcb --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt @@ -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(R.id.graphCpu) + val graphGpu = findViewById(R.id.graphGpu) + val graphNpu = findViewById(R.id.graphNpu) + val graphRam = findViewById(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(R.id.btnDebugToggle)?.setOnClickListener { + debugPanelVisible = !debugPanelVisible + setDebugPanelVisible(debugPanelVisible) + } + // Quit button + findViewById(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(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(R.id.debugPanel)?.visibility = visibility + findViewById(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, + 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 + } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/ChatAdapter.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/ChatAdapter.kt new file mode 100644 index 0000000..a3aed0d --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/ChatAdapter.kt @@ -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(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() { + override fun areItemsTheSame(old: ChatMessage, new: ChatMessage) = old.id == new.id + override fun areContentsTheSame(old: ChatMessage, new: ChatMessage) = old == new + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/MiniGraphView.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/MiniGraphView.kt new file mode 100644 index 0000000..2b4614b --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/MiniGraphView.kt @@ -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() + 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) + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/ResourceMonitor.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/ResourceMonitor.kt new file mode 100644 index 0000000..aeb5558 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/ResourceMonitor.kt @@ -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) { "" } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/SplashActivity.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/SplashActivity.kt new file mode 100644 index 0000000..45daeda --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/SplashActivity.kt @@ -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, 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() + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/vad/SileroVadEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/vad/SileroVadEngine.kt new file mode 100644 index 0000000..962e364 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/vad/SileroVadEngine.kt @@ -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? = 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)[0][0] + + // State "stateN": shape [2, batch, 128] = float[][][] + val stateTensor2 = results.get("stateN").get() + @Suppress("UNCHECKED_CAST") + val state3d = stateTensor2.value as Array> + 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 + } +} diff --git a/kazeia-android/app/src/main/jni/CMakeLists.txt b/kazeia-android/app/src/main/jni/CMakeLists.txt new file mode 100644 index 0000000..94a4cf2 --- /dev/null +++ b/kazeia-android/app/src/main/jni/CMakeLists.txt @@ -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) diff --git a/kazeia-android/app/src/main/jni/genie_jni.cpp b/kazeia-android/app/src/main/jni/genie_jni.cpp new file mode 100644 index 0000000..9ee1ddd --- /dev/null +++ b/kazeia-android/app/src/main/jni/genie_jni.cpp @@ -0,0 +1,201 @@ +#include +#include +#include + +#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(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(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(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(dialog)); + return 0; + } + + LOGI("Dialog created successfully: %p", dialog); + return reinterpret_cast(dialog); +} + +JNIEXPORT jstring JNICALL +Java_com_kazeia_llm_GenieJni_query( + JNIEnv* env, jobject, jlong dialogHandle, jstring prompt, jobject callback +) { + auto* dialog = reinterpret_cast(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(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(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" diff --git a/kazeia-android/app/src/main/jni/mel_extractor.cpp b/kazeia-android/app/src/main/jni/mel_extractor.cpp new file mode 100644 index 0000000..6e17cf1 --- /dev/null +++ b/kazeia-android/app/src/main/jni/mel_extractor.cpp @@ -0,0 +1,202 @@ +#include +#include +#include +#include +#include +#include + +#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 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 audio(targetLen, 0.0f); + for (int i = 0; i < std::min(audioLen, targetLen); i++) { + audio[i] = static_cast(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 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 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 mel_spec(N_MELS * N_FRAMES, 0.0f); + std::vector fft_real(FFT_N), fft_imag(FFT_N); + + // Pre-compute DFT twiddle factors for N_FFT=400, 201 output bins + std::vector cos_table(FFT_SIZE * N_FFT); + std::vector 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" diff --git a/kazeia-android/app/src/main/jni/whisper_jni.cpp b/kazeia-android/app/src/main/jni/whisper_jni.cpp new file mode 100644 index 0000000..49ac0f8 --- /dev/null +++ b/kazeia-android/app/src/main/jni/whisper_jni.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#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(ctx); +} + +JNIEXPORT jstring JNICALL +Java_com_kazeia_stt_WhisperJni_transcribe( + JNIEnv* env, jobject, jlong contextPtr, jshortArray audioData, jstring language +) { + auto* ctx = reinterpret_cast(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 pcmf32(audioLen); + float maxAbs = 0.0f; + for (int i = 0; i < audioLen; i++) { + float v = static_cast(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(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(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(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 pcmf32(audioLen); + float maxAbs = 0.0f; + for (int i = 0; i < audioLen; i++) { + float v = std::abs(static_cast(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(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" diff --git a/kazeia-android/app/src/main/res/drawable/bg_bubble_kazeia.xml b/kazeia-android/app/src/main/res/drawable/bg_bubble_kazeia.xml new file mode 100644 index 0000000..4efe606 --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_bubble_kazeia.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_bubble_patient.xml b/kazeia-android/app/src/main/res/drawable/bg_bubble_patient.xml new file mode 100644 index 0000000..eed29fd --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_bubble_patient.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_chat_panel.xml b/kazeia-android/app/src/main/res/drawable/bg_chat_panel.xml new file mode 100644 index 0000000..7925b0a --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_chat_panel.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_chat_panel_land.xml b/kazeia-android/app/src/main/res/drawable/bg_chat_panel_land.xml new file mode 100644 index 0000000..b68a877 --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_chat_panel_land.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_drag_handle.xml b/kazeia-android/app/src/main/res/drawable/bg_drag_handle.xml new file mode 100644 index 0000000..686ef5b --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_drag_handle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_input.xml b/kazeia-android/app/src/main/res/drawable/bg_input.xml new file mode 100644 index 0000000..398638a --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_input.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_mic_button.xml b/kazeia-android/app/src/main/res/drawable/bg_mic_button.xml new file mode 100644 index 0000000..15e72d0 --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_mic_button.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_mic_listening.xml b/kazeia-android/app/src/main/res/drawable/bg_mic_listening.xml new file mode 100644 index 0000000..e3c4ba7 --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_mic_listening.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_overlay_button.xml b/kazeia-android/app/src/main/res/drawable/bg_overlay_button.xml new file mode 100644 index 0000000..5823d0b --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_overlay_button.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/kazeia-android/app/src/main/res/drawable/bg_quit_button.xml b/kazeia-android/app/src/main/res/drawable/bg_quit_button.xml new file mode 100644 index 0000000..83deb66 --- /dev/null +++ b/kazeia-android/app/src/main/res/drawable/bg_quit_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/kazeia-android/app/src/main/res/layout/activity_chat.xml b/kazeia-android/app/src/main/res/layout/activity_chat.xml new file mode 100644 index 0000000..27167a4 --- /dev/null +++ b/kazeia-android/app/src/main/res/layout/activity_chat.xml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kazeia-android/app/src/main/res/layout/activity_splash.xml b/kazeia-android/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..20f6c1d --- /dev/null +++ b/kazeia-android/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/kazeia-android/app/src/main/res/layout/item_message.xml b/kazeia-android/app/src/main/res/layout/item_message.xml new file mode 100644 index 0000000..6a0e3b0 --- /dev/null +++ b/kazeia-android/app/src/main/res/layout/item_message.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/kazeia-android/app/src/main/res/layout/overlay_main.xml b/kazeia-android/app/src/main/res/layout/overlay_main.xml new file mode 100644 index 0000000..e098ff9 --- /dev/null +++ b/kazeia-android/app/src/main/res/layout/overlay_main.xml @@ -0,0 +1,271 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kazeia-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/kazeia-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..354c9f9 --- /dev/null +++ b/kazeia-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/kazeia-android/app/src/main/res/values/colors.xml b/kazeia-android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c4b4b7a --- /dev/null +++ b/kazeia-android/app/src/main/res/values/colors.xml @@ -0,0 +1,13 @@ + + + #6750A4 + #4A3880 + #E8DEF8 + #FEF7FF + #FFFBFE + #E8DEF8 + #F3EDF7 + #1C1B1F + #49454F + #6750A4 + diff --git a/kazeia-android/app/src/main/res/values/strings.xml b/kazeia-android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..61f81dd --- /dev/null +++ b/kazeia-android/app/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ + + + Kazeia + Écrivez votre message… + Envoyer + Microphone + En attente + Écoute en cours… + Transcription… + Kazeia réfléchit… + Kazeia parle… + Chargement des modèles… + Erreur + Kazeia Service + Kazeia est actif + Compagnon d\'écoute émotionnelle + diff --git a/kazeia-android/app/src/main/res/values/themes.xml b/kazeia-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..219ebd2 --- /dev/null +++ b/kazeia-android/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/kazeia-android/build.gradle.kts b/kazeia-android/build.gradle.kts new file mode 100644 index 0000000..1020b25 --- /dev/null +++ b/kazeia-android/build.gradle.kts @@ -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 +} diff --git a/kazeia-android/gradle.properties b/kazeia-android/gradle.properties new file mode 100644 index 0000000..4179118 --- /dev/null +++ b/kazeia-android/gradle.properties @@ -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= diff --git a/kazeia-android/gradle/wrapper/gradle-wrapper.properties b/kazeia-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..cea7a79 --- /dev/null +++ b/kazeia-android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/kazeia-android/gradlew b/kazeia-android/gradlew new file mode 100755 index 0000000..f3b75f3 --- /dev/null +++ b/kazeia-android/gradlew @@ -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" "$@" diff --git a/kazeia-android/gradlew.bat b/kazeia-android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/kazeia-android/gradlew.bat @@ -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 diff --git a/kazeia-android/settings.gradle.kts b/kazeia-android/settings.gradle.kts new file mode 100644 index 0000000..0c5b767 --- /dev/null +++ b/kazeia-android/settings.gradle.kts @@ -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") diff --git a/kazeia-architecture.md b/kazeia-architecture.md new file mode 100644 index 0000000..7c631e0 --- /dev/null +++ b/kazeia-architecture.md @@ -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.Idle) + val pipelineState: StateFlow = _pipelineState + + // Messages + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _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() + 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, + 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 + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## 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. diff --git a/scripts/cp_et_runner.cpp b/scripts/cp_et_runner.cpp new file mode 100644 index 0000000..2e86fb0 --- /dev/null +++ b/scripts/cp_et_runner.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +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(dmethod_meta("forward"); + ET_CHECK_MSG(method_meta.ok(), "Failed to get method meta"); + + std::vector> planned_bufs; + std::vector> 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(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 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(), emb, N_EMBD*4); + // Input 1: mask [1,1,1,16] + float* mp = method->mutable_input(1).toTensor().mutable_data_ptr(); + 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(), rcos+step*HD, HD*4); + // Input 3: sin [1,1,128] + memcpy(method->mutable_input(3).toTensor().mutable_data_ptr(), 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(), + kv.data()+(l*2)*kv_elem, kv_elem*4); + memcpy(method->mutable_input(5+l*2).toTensor().mutable_data_ptr(), + 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(); + + // 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(); + const float* vo = method->get_output(2+l*2).toTensor().const_data_ptr(); + 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(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; +} diff --git a/scripts/export_cp_pte.py b/scripts/export_cp_pte.py new file mode 100644 index 0000000..af73078 --- /dev/null +++ b/scripts/export_cp_pte.py @@ -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)") diff --git a/scripts/export_talker_pte.py b/scripts/export_talker_pte.py new file mode 100644 index 0000000..b3e85bc --- /dev/null +++ b/scripts/export_talker_pte.py @@ -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)") diff --git a/scripts/qc_schema_serialize_patched.py b/scripts/qc_schema_serialize_patched.py new file mode 100644 index 0000000..94dccee --- /dev/null +++ b/scripts/qc_schema_serialize_patched.py @@ -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") diff --git a/scripts/test_cp_et_quality.py b/scripts/test_cp_et_quality.py new file mode 100644 index 0000000..e05656d --- /dev/null +++ b/scripts/test_cp_et_quality.py @@ -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("