# 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.