kazeia/kazeia-architecture.md

831 lines
26 KiB
Markdown

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