kazeia/kazeia-architecture.md

26 KiB

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

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

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

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

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

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

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

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

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

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)

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 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 :

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 :

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