831 lines
26 KiB
Markdown
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.
|