kazeia/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt

1947 lines
100 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.AudioPlaybackManager
import com.kazeia.conversation.ConversationManager
import com.kazeia.conversation.PromptBuilder
import com.kazeia.conversation.StoppingCriteria
import com.kazeia.core.*
import com.kazeia.profiles.ConversationDao
import com.kazeia.profiles.ConversationDb
import com.kazeia.profiles.ProfileStore
import com.kazeia.profiles.Role
import com.kazeia.profiles.Turn
import com.kazeia.stt.WhisperHybridEngine
import com.kazeia.ui.ChatActivity
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.withLock
class KazeiaService : Service() {
companion object {
private const val TAG = "KazeiaService"
private const val NOTIFICATION_ID = 1
// Voix par défaut si le profil actif n'en définit aucune (ou mode invité).
const val DEFAULT_VOICE_ID = "damien"
// Diagnostic mode: when true, TTS is bypassed entirely. The chat
// bubble updates token-by-token as the LLM emits, so we can see
// the raw LLM speed (tok/s) without any TTS coupling. Flip back
// to false to re-enable the full STT→LLM→TTS pipeline.
const val LLM_ONLY_MODE = false
// Prompt système Thinker (Qwen3Guard-Gen-4B) — verbatim PoC validé.
private const val SYS_THINKER = """Tu es un psychologue clinicien experimente qui ecoute attentivement ton patient. Avant de repondre, tu prepares une analyse silencieuse pour orienter ta reponse. Produis UNIQUEMENT le bloc clinique au format strict suivant :
- emotion : <emotion principale ressentie, 5 mots max>
- besoin : <besoin sous-jacent du patient, 5 mots max>
- approche : <posture therapeutique adaptee, 5 mots max>
- axe : <point cle a valider ou explorer, 5 mots max>
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"""
}
private lateinit var llm: LlmEngine // Speaker (UnifiedLlmAdapter : GGUF défaut ou .pte)
// Cascade (off par défaut) : 2ᵉ moteur Thinker via UnifiedLlmAdapter,
// lazy au 1er tour (défaut Guard-4B .pte). nThreads réduit pour le Speaker.
private var thinkerEngine: com.kazeia.llm.UnifiedLlmAdapter? = null
private val thinkerLock = Object()
// Config runtime (cascade, prompts, modèles) pilotée par l'admin app via
// ContentProvider + ConfigStore. Snapshot mis à jour par le listener
// ConfigStore et par le BroadcastReceiver `com.kazeia.action.RELOAD_CONFIG`.
@Volatile
private var runtimeConfig: com.kazeia.config.ConfigStore.RuntimeConfig =
com.kazeia.config.ConfigStore.defaultConfig()
private var configReloadReceiver: android.content.BroadcastReceiver? = null
private var ragReloadReceiver: android.content.BroadcastReceiver? = null
// RAG optionnel (default OFF). Inerte tant que l'embedder kazeia-engine n'est
// pas prêt (embedText absent / modèle d'embedding non présent) -> aucune injection.
@Volatile private var rag: com.kazeia.rag.Rag? = null
// Sérialise build/teardown RAG : onCreate et un toggle config peuvent se
// chevaucher (deux coroutines) → sans verrou, deux EngineEmbedder natifs sont
// créés et le perdant fuit (jamais close()).
private val ragLock = Any()
private lateinit var stt: SttEngine
// Cycle de vie Whisper STT — lazy load + swap intelligent.
// Whisper consomme ~600 MB ION via QAIRT context binaries. Sur une tablette
// tendue mémoire, on le décharge pendant les phases LLM/TTS où il ne sert
// pas, et on le recharge en arrière-plan pendant le TTS playback (3-5 s
// disponibles) pour qu'il soit prêt au prochain PTT — zéro impact UX.
private var sttModelPath: String? = null
private val sttMutex = kotlinx.coroutines.sync.Mutex()
private suspend fun ensureSttLoaded() = sttMutex.withLock {
if (stt.isLoaded()) return@withLock
val path = sttModelPath ?: return@withLock
val t0 = System.currentTimeMillis()
log("[STT] ensure load…")
stt.load(path)
log("[STT] loaded in ${System.currentTimeMillis() - t0}ms")
}
private suspend fun releaseStt() = sttMutex.withLock {
if (!stt.isLoaded()) return@withLock
log("[STT] release (free ION ~600 MB)")
stt.release()
}
// Cycle de vie Speaker LLM — release+reload entre tours.
// resetContext() est silencieusement ignoré sur certains .pte Qwen3
// sharding=2 → cur_pos accumule → crash prefill après 3-5 tours.
// Solution robuste : reload complet pendant TTS playback (3-5s,
// caché derrière l'audio). Cost ~2-3s par turn, mais 0 latence user
// si le TTS playback dure plus long que le reload.
private val llmMutex = kotlinx.coroutines.sync.Mutex()
@Volatile private var llmReloadInFlight = false
private suspend fun ensureLlmLoaded() = llmMutex.withLock {
if (llm.isLoaded()) return@withLock
log("[LLM] ensure load (post-reload wait)…")
val t0 = System.currentTimeMillis()
llm.load("", com.kazeia.core.LlmConfig())
log("[LLM] loaded in ${System.currentTimeMillis() - t0}ms")
}
// reloadAfterTurn supprimé avec la refonte LLM 2026-06-17 : l'ancien hack
// ExecuTorchLlmEngine (reset cur_pos) n'a plus lieu d'être. UnifiedLlmAdapter
// gère l'état par tour (session reset GGUF ; .pte sans état persistant).
private suspend fun reloadLlmAfterTurn() { /* no-op */ }
private lateinit var tts: TtsEngine
private lateinit var audioPlayback: AudioPlaybackManager
private lateinit var conversationManager: ConversationManager
private lateinit var promptBuilder: PromptBuilder
private lateinit var stoppingCriteria: StoppingCriteria
// Per-interaction timing tracer. One instance, reset at every begin().
// All stage timings for a single user turn (STT+LLM+TTS+playback) end
// up in a single grep-friendly log section tagged "[PIPELINE]".
private val metrics = com.kazeia.core.PipelineMetrics(TAG).apply {
// Récap par tour (STT · Thinker · Speaker · TTS · total) routé vers les logs
// du service → visible dans le panneau debug patient, pas que dans logcat.
summarySink = { msg -> log(msg) }
// + encart PERSISTANT (ne défile pas) : input · output · temps du dernier tour.
timingsSink = { block ->
_lastTurnPanel.value = buildString {
append("🗣 Vous : "); append(lastTurnInput.ifBlank { "" }); append("\n\n")
append("🤖 Kazeia : "); append(lastTurnOutput.ifBlank { "" }); append("\n\n")
append(block)
}
}
}
private lateinit var voiceCommands: com.kazeia.conversation.VoiceCommandProcessor
// New modular pipeline
val pipeline = KazeiaPipeline()
private val _pipelineState = MutableStateFlow<PipelineState>(PipelineState.Idle)
val pipelineState: StateFlow<PipelineState> = _pipelineState
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
private val _logs = MutableStateFlow<List<String>>(emptyList())
val logs: StateFlow<List<String>> = _logs
// Encart « dernier tour » persistant (input · output · temps par étape).
private val _lastTurnPanel = MutableStateFlow("⏱ dernier tour : —")
val lastTurnPanel: StateFlow<String> = _lastTurnPanel
@Volatile private var lastTurnInput = ""
@Volatile private var lastTurnOutput = ""
// 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> = _aiWorkload
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var currentPipelineJob: kotlinx.coroutines.Job? = null
private val _isListening = MutableStateFlow(false)
val isListening: StateFlow<Boolean> = _isListening
// Drives the AudioVisualizerView orb. Pushed from the VAD loop
// during mic capture (mic RMS, normalized) and from the TTS engine's
// onSegmentPlaying callback (TTS RMS envelope per-segment). The view
// reads this via collectLatest in ChatActivity; the signals carry
// their own state so the visualizer knows whether it's idle, tracking
// the mic, or rendering a TTS segment.
sealed class VisualizerSignal {
object Idle : VisualizerSignal()
data class Listening(val micRms: Float) : VisualizerSignal()
/** L'orbe pulse une couleur smooth (LLM réfléchit). */
object Thinking : VisualizerSignal()
data class Speaking(
val rmsEnvelope: FloatArray,
val spectrogram: Array<FloatArray>,
val durationMs: Long
) : VisualizerSignal()
}
private val _visualizerSignal = MutableStateFlow<VisualizerSignal>(VisualizerSignal.Idle)
val visualizerSignal: StateFlow<VisualizerSignal> = _visualizerSignal
// Kazeia's orb color is bound to the selected voice so the user
// visually associates a palette with the speaker they picked. UI
// sets this whenever the voice spinner changes; the orb view
// listens via the StateFlow and tweens the current → target color.
private val _voiceColor = MutableStateFlow(0xFFBCA4E8.toInt()) // lavender = Damien default
val voiceColor: StateFlow<Int> = _voiceColor
/** Called by the UI whenever the voice selector changes. */
fun setVoiceColor(color: Int) { _voiceColor.value = color }
private val _debugMode = MutableStateFlow(false)
val debugMode: StateFlow<Boolean> = _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> = _loadingState
/**
* Cascade : crée le 2ᵉ moteur (Thinker, défaut Guard-4B .pte NPU) via
* UnifiedLlmAdapter dans le même process, lazy au 1er tour. Off par défaut
* (cascadeEnabled=false). nThreads réduit pour laisser des cœurs au Speaker.
*/
private fun ensureThinkerEngine() {
synchronized(thinkerLock) {
if (thinkerEngine != null && thinkerEngine!!.isLoaded()) return
val cfg = runtimeConfig
val info = com.kazeia.config.ModelRegistry.byId(cfg.thinker.modelId)
?: com.kazeia.config.ModelRegistry.defaultThinker()
log("[THINKER] loading ${info.displayName} engine in-process (lazy first PTT)…")
val tStart = System.currentTimeMillis()
val memBefore = readMemAvailableMb()
// Thinker via UnifiedLlmAdapter : Guard-4B .pte (NPU) ou tout GGUF du registre.
val isPte = info.backend == com.kazeia.config.ModelRegistry.Backend.PTE
val e = com.kazeia.llm.UnifiedLlmAdapter(
modelPath = if (isPte) info.ptePath() else info.ggufPath(),
systemPrompt = cfg.thinker.systemPrompt,
ctx = if (isPte) info.maxSeqLen else 4096,
nThreads = 4, // cascade : laisser des cœurs au Speaker
tokenizerPath = if (isPte) info.tokenizerPath() else null,
onLog = { msg -> log("[THINKER] $msg") }
)
try {
runBlocking { e.load("", com.kazeia.core.LlmConfig()) }
if (!e.isLoaded()) throw IllegalStateException("Thinker load() returned but isLoaded=false")
thinkerEngine = e
val dt = System.currentTimeMillis() - tStart
val memAfter = readMemAvailableMb()
log("[THINKER] engine READY in ${dt}ms (Δmem ${memBefore - memAfter}MB)")
} catch (e: Exception) {
log("[THINKER] engine load failed: ${e.javaClass.simpleName}: ${e.message} — cascade off")
thinkerEngine = null
}
}
}
private fun stopThinkerEngine() {
synchronized(thinkerLock) {
thinkerEngine?.let { try { it.release() } catch (_: Exception) {} }
thinkerEngine = null
}
}
/** Lecture rapide MemAvailable pour traçage cascade memory footprint. */
private fun readMemAvailableMb(): Long {
return try {
java.io.File("/proc/meminfo").readLines()
.firstOrNull { it.startsWith("MemAvailable:") }
?.trim()?.split(Regex("\\s+"))?.get(1)?.toLong()?.div(1024) ?: 0L
} catch (_: Exception) { 0L }
}
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 {
intent?.getStringExtra("tts_text")?.let { text ->
val savePath = intent.getStringExtra("save_wav")
if (savePath != null) {
val voiceOverride = intent.getStringExtra("tts_voice")
log("TTS generate WAV: '$text' → $savePath${voiceOverride?.let { " voice=$it" } ?: ""}")
serviceScope.launch {
try {
if (voiceOverride != null) { setVoice(voiceOverride); kotlinx.coroutines.delay(50) }
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)
}
}
intent?.getStringExtra("llm_text")?.let { prompt ->
// Affordance de test LLM pur (sans TTS) : génère et logge la réponse.
// Sert à valider un Speaker (ex. Qwen2.5-7B GGUF) hors pipeline audio.
val maxTok = intent.getIntExtra("llm_max", 120)
log("LLM test: '${prompt.take(80)}' (max=$maxTok)")
serviceScope.launch {
if (!::llm.isInitialized || !llm.isLoaded()) { log("LLM test: not ready"); return@launch }
try {
val t0 = System.currentTimeMillis()
val r = llm.generate(prompt, com.kazeia.core.SamplingParams(maxNewTokens = maxTok, temperature = 0.7f), null)
val ms = System.currentTimeMillis() - t0
log("LLM test OUT (${r.tokenCount} word/tok, ${ms}ms, cap=$maxTok, ${r.text.length} chars): ${r.text}")
} catch (e: Exception) { log("LLM test error: ${e.message}") }
}
}
intent?.getStringExtra("rag_test")?.let { query ->
// Valide TOUTE la chaîne RAG (chunk -> embed -> SQLite -> index -> retrieve)
// SANS kazeia-engine, via FakeEmbedder. Seuil 0 (le fake a un signal faible).
log("RAG test: '$query'")
serviceScope.launch {
try {
val rag = com.kazeia.rag.Rag(this@KazeiaService, com.kazeia.rag.FakeEmbedder(), "fake-test") { m -> log(m) }
rag.clear()
rag.ingest("insomnie", "L'insomnie est un trouble du sommeil fréquent. Pour mieux dormir, évitez les écrans le soir et gardez une heure de coucher régulière. La respiration lente aide à l'endormissement.")
rag.ingest("solitude", "Le sentiment de solitude peut être douloureux. Renouer un petit lien social, même bref, aide beaucoup. Noter trois choses positives par jour réduit la rumination.")
rag.loadIndex()
val ctx = rag.retrieve(query, threshold = 0.0f)
log("RAG test RESULT (corpus=${rag.count()} chunks):\n${ctx ?: "(rien au-dessus du seuil)"}")
} catch (e: Exception) { log("RAG test error: ${e.message}"); e.printStackTrace() }
}
}
intent?.getStringExtra("rag_real_test")?.let { query ->
// Valide le VRAI EngineEmbedder in-app (e5-small via kazeia-engine CPU-only) :
// prouve loadEmbedder/embedText sans crash SELinux + retrieval FR réel.
log("RAG real test: '$query'")
serviceScope.launch {
try {
val embModel = "${KazeiaApplication.MODELS_DIR}/embed-e5-small.gguf"
val emb = com.kazeia.rag.EngineEmbedder(embModel, nThreads = 4, pooling = -1,
queryPrefix = "query: ", passagePrefix = "passage: ", log = { m -> log(m) })
log("RAG real: embedder ready=${emb.isReady} dim=${emb.dim}")
if (!emb.isReady) { log("RAG real: embedder NON prêt — abort"); return@launch }
val rag = com.kazeia.rag.Rag(this@KazeiaService, emb, "e5-small") { m -> log(m) }
rag.clear()
rag.ingest("insomnie", "L'insomnie est un trouble du sommeil fréquent. Pour mieux dormir, évitez les écrans le soir et gardez une heure de coucher régulière.")
rag.ingest("solitude", "Le sentiment de solitude peut être douloureux. Renouer un petit lien social, même bref, aide beaucoup.")
rag.ingest("respiration", "La respiration lente et profonde calme l'anxiété : inspirez quatre secondes, expirez six secondes, répétez.")
rag.loadIndex()
val ctx = rag.retrieve(query)
log("RAG real RESULT (corpus=${rag.count()}):\n${ctx ?: "(rien au-dessus du seuil)"}")
emb.close()
} catch (e: Exception) { log("RAG real test error: ${e.message}"); e.printStackTrace() }
}
}
intent?.getStringExtra("rag_ingest")?.let { dirArg ->
// (Ré)ingestion du corpus dans l'instance RAG live : clear + ingestDir.
// dirArg vide -> dossier corpus par défaut. Brique de l'app admin future.
val dir = dirArg.ifBlank { com.kazeia.KazeiaPaths.ragCorpusDir }
log("RAG ingest: $dir")
serviceScope.launch {
val r = rag
if (r == null || !r.isReady) { log("RAG ingest: RAG non prêt (ragEnabled off ?)"); return@launch }
r.clear()
val n = r.ingestDir(dir)
log("RAG ingest terminé: $n chunks (corpus=${r.count()})")
}
}
if (intent?.hasExtra("cfg_set") == true) {
// Maintenance/bench : persiste engine+model+prompt sans passer par le
// ContentProvider CLI (qui casse sur les valeurs contenant ':'). Les
// extras passent par `am --es` (pas de split sur ':'). cfg_sys="DEFAULT"
// résout le prompt thérapeutique par défaut en interne (jamais transporté).
val store = com.kazeia.config.ConfigStore.get(this)
val cur = store.current()
val eng = intent.getStringExtra("cfg_engine") ?: cur.llmEngine
val mid = intent.getStringExtra("cfg_model") ?: cur.speaker.modelId
val sysRaw = intent.getStringExtra("cfg_sys")
val sys = when (sysRaw) {
null -> cur.speaker.systemPrompt
"DEFAULT" -> com.kazeia.config.ConfigStore.DEFAULT_SPEAKER_PROMPT
else -> sysRaw
}
val newCfg = cur.copy(llmEngine = eng, speaker = cur.speaker.copy(modelId = mid, systemPrompt = sys))
store.save(newCfg)
log("cfg_set: engine=$eng model=$mid sysLen=${sys.length}")
}
return START_STICKY
}
override fun onCreate() {
super.onCreate()
// Charge la config runtime au plus tôt + s'abonne aux changements
// (admin app → ContentProvider.update → ConfigStore.save → listener)
val store = com.kazeia.config.ConfigStore.get(this)
runtimeConfig = store.current()
_debugMode.value = runtimeConfig.debugEnabled // gate UI debug (bouton logs/métriques)
store.addListener { new -> handleConfigChange(new) }
registerConfigReloadReceiver()
registerRagReloadReceiver()
val hasMicPermission = ContextCompat.checkSelfPermission(
this, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
// FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK is required so ColorOS (and
// stock Android 14+ policies) don't mute the TTS AudioTrack with
// "clientVolume" at ~600 ms after play(). Without it the FGS was
// classified as mic-only or special-use and background-audio
// hardening silenced it. Combine with MICROPHONE so mic input keeps
// working during STT.
val fgsType = if (hasMicPermission) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
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
// VAD opérationnelle = RMS énergie inline dans startContinuousListening().
// SileroVadEngine + AudioCaptureManager (legacy code mort) supprimés
// 2026-05-02 — cf /opt/Kazeia/kazeia-stt-perf-validated/.
val nativeLibDir = applicationInfo.nativeLibraryDir
// TTS = CosyVoice (seul moteur depuis la bascule 2026-06-18 ; les
// moteurs legacy Qwen3-TTS/lib ont été retirés). Clonage vocal 9
// langues, CPU-only, RTF ~1.0.
_loadingState.value = LoadingState(15, "TTS CosyVoice…")
val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
cv.load()
if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
tts = cv
log("TTS: CosyVoiceTtsEngine loaded (clonage vocal CPU-only)")
// Pré-chauffe le cache voix EN TÂCHE DE FOND (chevauche le reste du
// chargement + la 1ʳᵉ phrase de l'usager) → le 1er vrai tour part
// chaud (~3 s) au lieu de ~10 s (remplissage de cache à froid).
serviceScope.launch(Dispatchers.IO) {
runCatching { cv.warmup() }
}
_loadingState.value = LoadingState(20, "TTS OK")
// STT = Whisper-Small NPU (HfWhisper Qualcomm) — voie validée
// (cf /opt/Kazeia/kazeia-stt-perf-validated/). Pas de fallback.
// Lazy-load + swap intelligent : on construit le moteur ici mais
// on diffère le load() au 1ᵉʳ PTT pour économiser ~600 MB ION
// au boot. Hooks sur _pipelineState gèrent reload anticipé
// pendant TTS (caché derrière le playback) et release pendant
// les phases LLM/TTS où Whisper ne sert pas.
_loadingState.value = LoadingState(25, "STT Whisper (lazy)…")
// STT = WhisperHybridEngine (NPU, gelé) — SEUL backend depuis le retrait
// du backend "lib" (KazeiaSttAdapter/libkazeia_stt) non validé, 2026-06-18.
// ConfigStore.sttEngine est désormais vestigial (compat config).
val npuStt: com.kazeia.core.SttEngine = WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") }
sttModelPath = "$modelsDir/whisper-small-sm8750"
stt = npuStt
log("Whisper créé (lazy load au 1er PTT)")
addMessage(ChatMessage(
role = ChatMessage.Role.SYSTEM,
text = "STT: Whisper-Small NPU"
))
// LLM = ExecuTorch QNN. Modèle choisi dans la config runtime
// (admin app peut le changer à chaud). Fallback sur le
// Speaker par défaut du registre si l'id config est inconnu.
// LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md) :
// UnifiedLlmAdapter détecte le format par magic-byte et charge le bon
// moteur — GGUF (Qwen3.5-4B hybride, défaut) sur CPU i8mm avec cache-
// préfixe, ou .pte (denses Qwen3-4B/8B, Qwen2.5-7B) sur NPU V79 (sans
// root). Le flag ConfigStore.llmEngine n'est plus déterminant (auto-détecté).
val sp = com.kazeia.config.ModelRegistry.byId(runtimeConfig.speaker.modelId) ?: com.kazeia.config.ModelRegistry.defaultSpeaker()
_loadingState.value = LoadingState(50, "LLM ${sp.id}")
val isPte = sp.backend == com.kazeia.config.ModelRegistry.Backend.PTE
val mPath = if (isPte) sp.ptePath() else sp.ggufPath()
val tkPath = if (isPte) sp.tokenizerPath() else null
val ctxLen = if (isPte) sp.maxSeqLen else 4096
log("[LLM] speaker=${sp.id} backend=${sp.backend} path=$mPath ctx=$ctxLen")
llm = com.kazeia.llm.UnifiedLlmAdapter(
mPath, runtimeConfig.speaker.systemPrompt,
ctx = ctxLen, nThreads = 6, tokenizerPath = tkPath,
onLog = { msg -> log(msg) }
)
try {
llm.load("", com.kazeia.core.LlmConfig())
log("LLM créé : ${sp.id} (${sp.backend})")
} catch (e: Throwable) {
log("LLM load failed (${e.message}) — mode écho jusqu'au prochain essai")
}
// RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la
// lib n'expose pas encore embedText ou si le modèle d'embedding est absent
// (isReady=false -> retrieve() renvoie null -> aucune injection).
rag = buildRag()
_loadingState.value = LoadingState(80, "Audio…")
// Audio
audioPlayback = AudioPlaybackManager()
// Conversation logic
promptBuilder = PromptBuilder()
stoppingCriteria = StoppingCriteria()
conversationManager = ConversationManager()
voiceCommands = com.kazeia.conversation.VoiceCommandProcessor(this@KazeiaService)
log("Voice commands loaded: ${voiceCommands.getCommands().size} commands")
// Voix = celle du PROFIL ACTIF (choisie dans l'app admin). Plus de
// sélecteur côté patient. Re-appliquée quand le profil actif change
// ou que l'admin édite la voix d'un profil (ProfileStore notifie).
applyActiveProfileVoice()
lastActiveProfileId = ProfileStore.get(applicationContext).activeProfile()?.id
if (!voiceProfileListenerRegistered) {
ProfileStore.get(applicationContext).addListener { onProfileStoreChanged() }
voiceProfileListenerRegistered = true
}
addMessage(ChatMessage(
role = ChatMessage.Role.KAZEIA,
text = if (llm.isLoaded()) "Bonjour, je suis Kazeia."
else "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})")
// Cycle de vie STT (option A+B) : decharge Whisper pendant LLM,
// recharge anticipée pendant TTS playback (caché derrière 3-5s
// d'audio). Au prochain PTT, Whisper est déjà prêt → 0 latence.
serviceScope.launch {
var lastTriggered: String? = null
_pipelineState.collect { st ->
val key = when (st) {
is PipelineState.Thinking, is PipelineState.TokenGenerated -> "release"
is PipelineState.Speaking -> "ensure"
else -> null
}
if (key != null && key != lastTriggered) {
lastTriggered = key
launch {
if (key == "release") releaseStt() else ensureSttLoaded()
}
// Speaking = TTS playback démarre → on a 3-5s pour
// recharger le Speaker LLM en arrière-plan, garantir
// un cur_pos propre pour le prochain tour.
// 2026-05-13 : reloadLlmAfterTurn() désactivé.
// Le release+reload échoue avec
// "Failed to load llm runner: [1]" sur seq=1024 .pte
// (probable bug singleton QnnBackendUnifiedRegistry).
// On compte sur resetContext() en début de generate()
// pour cur_pos_=0 (qui appelle Runner::reset côté C++).
}
}
}
} 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
}
}
}
// ─── Historique conversation (2c.4) ────────────────────────────────────
// DAO SQLCipher lazy : la base n'est ouverte qu'au 1er tour loggé. En
// mode invité (pas de profil actif) rien n'est ouvert ni écrit.
private val conversationDao: ConversationDao by lazy {
ConversationDao(ConversationDb.get(applicationContext))
}
// Nom de la voix courante (id sans extension), tracké pour le log de tour.
@Volatile private var currentVoiceName: String = DEFAULT_VOICE_ID
@Volatile private var voiceProfileListenerRegistered = false
/**
* Persiste un tour de conversation pour le profil actif. No-op en mode
* invité (pas de profil → pas de session). L'écriture SQLCipher part sur
* un coroutine IO pour ne pas bloquer le pipeline.
*/
private fun logConversationTurn(
role: Role,
text: String,
ttftMs: Long? = null,
totalMs: Long? = null
) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
val store = ProfileStore.get(applicationContext)
val profile = store.activeProfile() ?: return // mode invité
val sessionId = store.currentSessionId ?: return
serviceScope.launch(Dispatchers.IO) {
try {
conversationDao.logTurn(
Turn(
profileId = profile.id,
sessionId = sessionId,
timestamp = System.currentTimeMillis(),
role = role,
text = trimmed,
ttftMs = ttftMs,
totalMs = totalMs,
voiceUsed = if (role == Role.KAZEIA) currentVoiceName else null,
modelUsed = if (role == Role.KAZEIA) runtimeConfig.speaker.modelId else null
)
)
} catch (e: Exception) {
log("[HISTORY] log turn failed: ${e.message}")
}
}
}
@Volatile private var lastActiveProfileId: String? = null
/** Réaction à un changement ProfileStore (admin édite un profil, ou le profil
* actif change). Réapplique la voix ; et si le PATIENT ACTIF a changé, vide la
* mémoire conversationnelle du LLM (sinon les échanges d'un patient fuiteraient
* vers le suivant — confidentialité clinique). */
private fun onProfileStoreChanged() {
applyActiveProfileVoice()
val nowId = ProfileStore.get(applicationContext).activeProfile()?.id
if (nowId != lastActiveProfileId) {
lastActiveProfileId = nowId
(llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
log("[LLM] profil actif changé → mémoire de session réinitialisée")
}
}
/** Applique au moteur TTS la voix du PROFIL ACTIF (ou [DEFAULT_VOICE_ID] en
* mode invité / profil sans voix). Source unique de vérité = l'app admin. */
private fun applyActiveProfileVoice() {
if (!::tts.isInitialized) return
val vid = ProfileStore.get(applicationContext).activeProfile()
?.voiceId?.takeIf { it.isNotBlank() } ?: DEFAULT_VOICE_ID
setVoiceId(vid)
}
/** Applique une voix par IDENTIFIANT (nom sans extension), résolu selon le
* moteur TTS actif : CosyVoice → cosyvoice/<id>.cvps ; Qwen3 / lib →
* ../voix/<id>.wav. Hot-swap : effet au prochain segment synthétisé. */
fun setVoiceId(voiceId: String) {
currentVoiceName = voiceId
when (val e = tts) {
is com.kazeia.tts.CosyVoiceTtsEngine -> { e.setVoice(voiceId); log("Voix (cosyvoice): $voiceId") }
else -> log("setVoiceId: moteur TTS inconnu — voix '$voiceId' ignorée")
}
}
/** Back-compat (intents de test `tts_voice`) : accepte un chemin OU un id et
* délègue à [setVoiceId] (qui résout selon le moteur actif). */
fun setVoice(voicePathOrId: String) =
setVoiceId(voicePathOrId.substringAfterLast('/').substringBeforeLast('.'))
// True dès que le 1er son de la réponse est audible (la 1ʳᵉ phrase TTS joue).
// Pendant la génération LLM + la synthèse TTS (avant tout son), l'entrée est
// VERROUILLÉE : un tap/bruit ne doit pas perturber le pipeline (half-duplex).
@Volatile private var audiblePlayback = false
/** Le pipeline produit la réponse mais rien n'est encore audible (LLM ou TTS
* en cours) → on ignore toute entrée utilisateur jusqu'au 1er son / fin de tour. */
private fun producingBeforeAudio(): Boolean {
val s = _pipelineState.value
val busy = s is PipelineState.Thinking || s is PipelineState.Transcribing ||
s is PipelineState.Transcribed || s is PipelineState.TokenGenerated ||
s is PipelineState.Speaking
return busy && !audiblePlayback
}
fun interruptTts() {
// N'interrompre QUE si la réponse est réellement en train de jouer.
if (_pipelineState.value is PipelineState.Speaking && audiblePlayback) {
log("TTS interrupted by user")
tts.stop()
_pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle
}
}
@Volatile private var pttRecording = false
fun toggleListening() {
// Verrou half-duplex : pendant la génération LLM / synthèse TTS (avant le 1er
// son), on ignore le bouton micro — sinon un tap casse la synthèse en cours
// (« play() on uninitialized AudioTrack », tour avorté). On réaccepte l'entrée
// une fois l'audio audible (l'usager peut alors interrompre).
if (producingBeforeAudio()) {
log("toggleListening ignoré — génération/synthèse en cours (entrée verrouillée)")
return
}
// Audio en cours → l'usager a le droit d'interrompre.
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 = RMS énergie inline)")
_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<ShortArray>()
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) {
// Ensure Whisper STT loaded (lazy 1ᵉʳ PTT, ou re-load après swap).
// Bloquant côté coroutine pour garantir que stt.transcribe() ne soit
// pas appelé sur un moteur déchargé en plein milieu de la boucle.
ensureSttLoaded()
// Speaker LLM doit aussi être prêt — au cas où la phase TTS du tour
// précédent ait été plus courte que le reload (rare mais possible).
ensureLlmLoaded()
val sampleRate = 16000
val frameSize = 1600 // 100ms frames for VAD
val silenceThreshold = 150 // RMS threshold for speech detection.
// Mesures terrain : bruit ambiant 86-91 RMS, click PTT 116 RMS,
// parole douce 500+ RMS, parole forte 700-3000 RMS. 80 était trop
// bas → bruit ambiant > seuil → silence jamais détecté →
// segment ne se finalise pas. 150 = au-dessus du bruit + click,
// bien en-dessous de toute parole humaine.
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<ShortArray>()
var speechFrameCount = 0
var silenceFrameCount = 0
var isSpeechActive = false
var frameCount = 0
// Warmup window : AudioRecord retourne du bruit non stabilisé dans les
// ~500 ms après start (click PTT, mic init). Sans warmup, le VAD
// déclenche sur ce bruit, Whisper hallucine "Bonsoir" sur silence,
// et la 1ère phrase utilisateur arrive après plusieurs faux départs.
val listenStartMs = System.currentTimeMillis()
val warmupMs = 500L
var speechStartTime = 0L
val streamingIntervalSamples = sampleRate * 2 // send to Whisper every 2s of speech
var lastStreamedSamples = 0
var streamingResults = mutableListOf<String>()
var wasMuted = false
var lastBusyMs = 0L
// Pré-roll : ~300 ms de frames sous le seuil gardées en anneau pour
// capturer l'attaque consonantique (« b », « p », « t »). Sans ça le
// buffer speech ne démarrait qu'APRÈS le franchissement du seuil RMS,
// mangeant le début de chaque phrase (bug #1, « 1ʳᵉ phrase ratée »).
val preRoll = ArrayDeque<ShortArray>()
val preRollMax = 3
while (_isListening.value) {
val read = recorder.read(frame, 0, frameSize)
if (read != frameSize) continue
val now = System.currentTimeMillis()
// Anti-feedback acoustique : le micro capte la sortie HP et
// Whisper transcrit ces échos en gibberish ("page beg, beg")
// → boucle infinie. On mute le mic dès que la machine
// travaille (Thinking, Speaking, Transcribing, etc.). Seuls
// Listening / Idle / SpeechDetected acceptent les frames.
val st = _pipelineState.value
val machineBusy = st is PipelineState.Speaking
|| st is PipelineState.Thinking
|| st is PipelineState.Transcribing
|| st is PipelineState.Transcribed
|| st is PipelineState.TokenGenerated
val nowMs = System.currentTimeMillis()
// Tail buffer : marge anti-écho après la fin du Speaking. CosyVoice
// joue via AudioTrack et suspend jusqu'au marker (drain RÉEL des
// échantillons) — il n'y a plus de queue MediaPlayer à absorber.
// 800 ms (calibré pour l'ancien MediaPlayer) mangeait le début de
// la réponse de l'usager juste après que Kazeia a fini de parler
// (bug #1). 300 ms suffit pour la latence HP/DAC résiduelle.
if (machineBusy) {
lastBusyMs = nowMs
}
val inTailBuffer = (nowMs - lastBusyMs) < 300
if (machineBusy || inTailBuffer) {
if (isSpeechActive) {
speechBuffer.clear(); speechFrameCount = 0; silenceFrameCount = 0
isSpeechActive = false; lastStreamedSamples = 0; streamingResults.clear()
}
if (!wasMuted) { log("Mic muted (state=${st::class.simpleName} tail=${inTailBuffer})"); wasMuted = true }
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()
// Drive the visualizer orb. Normalize with the same
// sqrt squashing used for TTS so loud peaks don't
// saturate and quiet speech is still visible.
// IMPORTANT : ne PAS écraser un signal Thinking/Speaking en
// cours (l'app est entre 2 tours, le LLM/TTS est en train
// de générer la réponse, l'orbe doit rester en cycle de
// couleurs/waveform et pas repasser en Listening).
val rmsNorm = kotlin.math.sqrt((rms / 6000f).coerceIn(0f, 1f))
val cur = _visualizerSignal.value
if (cur is VisualizerSignal.Idle || cur is VisualizerSignal.Listening) {
_visualizerSignal.value = VisualizerSignal.Listening(rmsNorm)
}
// Log RMS every second for calibration
if (frameCount % 10 == 0) {
Log.d(TAG, "VAD RMS=$rms (threshold=$silenceThreshold)")
}
frameCount++
val isSpeech = rms > silenceThreshold
// Pendant le warmup, on ignore tout candidat speech : audio
// pas encore stabilisé, RMS peut être élevé sur du bruit
// mécanique (click PTT, capacitive feedback) → Whisper
// hallucinerait. On laisse aussi le buffer vide.
val inWarmup = (System.currentTimeMillis() - listenStartMs) < warmupMs
if (isSpeech && !inWarmup) {
silenceFrameCount = 0
if (speechFrameCount == 0 && preRoll.isNotEmpty()) {
// Début d'un candidat speech : injecter l'attaque captée
// juste avant le franchissement du seuil (sinon clippée).
for (pf in preRoll) speechBuffer.add(pf)
preRoll.clear()
}
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()) {
val trimmed = result.text.trim()
val previousText = streamingResults.lastOrNull()
streamingResults.clear()
streamingResults.add(trimmed)
log("Streaming result (${elapsed}ms): \"$trimmed\"")
_pipelineState.value = PipelineState.StreamingTranscript(trimmed)
// Finalisation par STABILITÉ : si Whisper sort 2× le même
// texte d'affilée, l'utilisateur a fini de parler.
// Permet de boucler la cascade sans attendre 800ms de
// silence VAD (qui ne se déclenche pas si bruit ambiant
// > seuil 80) ni le PTT release manuel.
if (previousText != null && previousText == trimmed) {
log("Streaming stable (2× '$trimmed') → finalize early")
// Force segment end : suffit d'incrémenter
// silenceFrameCount au-dessus du seuil pour
// déclencher la branche de finalisation.
silenceFrameCount = silenceEndFrames + 1
}
}
}
}
} 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
// Alimente le pré-roll (hors warmup) : on garde les
// dernières frames calmes pour préfixer l'attaque de la
// prochaine phrase (bug #1).
if (!inWarmup) {
preRoll.addLast(frame.copyOf())
while (preRoll.size > preRollMax) preRoll.removeFirst()
}
}
}
// 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")
// PTT release : finaliser le segment en cours s'il y a du speech bufferisé.
// Sans ça, si le bruit ambiant flotte juste au-dessus du seuil VAD le
// segment ne se ferme jamais via silence detection → le LLM n'est jamais
// déclenché. On utilise le streaming result si dispo, sinon transcription
// complète du buffer.
if (isSpeechActive && speechBuffer.isNotEmpty()) {
val pendingMs = speechBuffer.sumOf { it.size } * 1000 / sampleRate
log("PTT release: forcing finalization of pending segment (${pendingMs}ms)")
val pending = ShortArray(speechBuffer.sumOf { it.size })
var off = 0
for (chunk in speechBuffer) { chunk.copyInto(pending, off); off += chunk.size }
val streamingText = streamingResults.joinToString(" ").trim()
serviceScope.launch(Dispatchers.IO) {
if (streamingText.isNotEmpty()) {
log("PTT-finalize using streaming result: \"$streamingText\"")
_pipelineState.value = PipelineState.Transcribed(streamingText)
if (!handleVoiceCommand(streamingText)) {
addMessage(ChatMessage(role = ChatMessage.Role.PATIENT, text = streamingText))
processLlmResponse(streamingText)
}
} else {
log("PTT-finalize via full Whisper transcription (no streaming text)")
_pipelineState.value = PipelineState.Transcribing
processSpeechInput(pending)
}
}
}
} 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 {
// Robustesse : une entrée (texte/intent) arrivée avant la fin de l'init du
// service ne doit pas crasher sur le lateinit non encore posé.
if (!::voiceCommands.isInitialized) return false
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()
// Vide aussi la mémoire conversationnelle du LLM (session engine).
(llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
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 {
// Route the "repeat last answer" command through the
// streaming TTS session so the user hears the first
// sentence immediately instead of waiting for a full
// re-synthesis of a long previous response.
pipeline.speakText(lastKazeia.text)
_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
// L'AudioRecord de startContinuousListening / startPushToTalkRecording
// est piloté par _isListening.value : la boucle while sort sur false.
_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}")
metrics.begin("speech", "${audioMs}ms audio")
metrics.mark("STT.start", "audio=${audioMs}ms engine=${stt::class.simpleName}")
val t0 = System.currentTimeMillis()
val transcription = stt.transcribe(audioData, language = "fr")
val whisperMs = System.currentTimeMillis() - t0
metrics.mark("STT.end",
"text=\"${transcription.text.take(60)}\" dur=${whisperMs}ms rtf=${"%.2f".format(whisperMs.toFloat() / audioMs)}")
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'")
// For text input, the pipeline clock starts here (no STT stage).
// Speech path opens metrics itself in processSpeechInput before
// calling processLlmResponse, so we only begin() on the text path.
if (!text.trim().lowercase().let {
it.startsWith("pipeline") || it.startsWith("!pipeline") ||
it.startsWith("\\!pipeline") || it == "go"
}) {
metrics.begin("text", text)
}
serviceScope.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
lastTurnInput = patientMessage // encart « dernier tour »
lastTurnOutput = ""
conversationManager.onNewTurn()
// Historique : tour patient persisté avant toute génération (no-op
// en mode invité). Le tour Kazeia est loggé en fin de génération.
logConversationTurn(Role.PATIENT, patientMessage)
// TTS désactivable à chaud depuis l'app admin (runtimeConfig.ttsEnabled)
// ou via la constante de dev LLM_ONLY_MODE. Si le TTS est coupé : aucune
// synthèse vocale, la bulle se remplit progressivement, token par token.
val llmOnly = LLM_ONLY_MODE || !runtimeConfig.ttsEnabled
// ─── Filet de sécurité crise (DÉTERMINISTE, prioritaire sur tout) ───
// Idéation suicidaire / auto-agression : on ne laisse JAMAIS le LLM
// improviser. Réponse fixe, validée, qui oriente vers l'humain (112).
if (com.kazeia.safety.CrisisGuard.detect(patientMessage)) {
val safe = com.kazeia.safety.CrisisGuard.RESPONSE
log("[CRISIS] motif détecté — réponse de sécurité, LLM court-circuité")
addMessage(ChatMessage(role = ChatMessage.Role.KAZEIA, text = safe))
logConversationTurn(Role.KAZEIA, safe)
if (!llmOnly) pipeline.speakText(safe)
_pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle
return
}
// If LLM not loaded, use echo mode with TTS. Route through the
// streaming session — in debug builds this is the fast path most
// exercised, and it benefits from per-sentence playback like any
// real response would.
if (!llm.isLoaded()) {
log("Echo mode: '$patientMessage' → TTS (${tts::class.simpleName})")
val echoResponse = patientMessage
addMessage(ChatMessage(role = ChatMessage.Role.KAZEIA, text = echoResponse))
metrics.mark("LLM.skipped", "echo_mode")
if (!llmOnly) pipeline.speakText(echoResponse)
metrics.end("echo")
_pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle
return
}
// Signal visuel Thinking ASAP (cycle de couleurs sur l'orbe) : couvre
// toute la cascade (Thinker pass + Speaker pass), repasse à Speaking
// automatiquement dès le 1er audio TTS.
_visualizerSignal.value = VisualizerSignal.Thinking
// ─── Cascade Option E v2 : DÉSACTIVÉE 2026-05-04 ────────────────
// Guard-0.6B produit des bullets que le Speaker régurgite verbatim
// dans sa réponse, malgré l'injection en system prompt + instruction
// "NE PAS RÉPÉTER". Cause : Guard est un classifieur safety, pas un
// Cascade pilotée par la config runtime (admin app). Quand cascade=ON,
// le Thinker analyse le message patient et produit des bullets injectées
// au Speaker. Quand OFF (défaut FROZEN), le Speaker reçoit le message
// directement avec son system prompt seul.
val cfg = runtimeConfig
val CASCADE_ENABLED = cfg.cascadeEnabled
val cascadeBullets: String = try {
if (CASCADE_ENABLED) ensureThinkerEngine()
val tE = if (CASCADE_ENABLED) thinkerEngine else null
if (tE != null && tE.isLoaded()) {
metrics.mark("THINKER.start", "msg_chars=${patientMessage.length}")
val tStart = System.currentTimeMillis()
val result = tE.generateWithSystem(
prompt = "Message du patient : $patientMessage",
systemPromptOverride = cfg.thinker.systemPrompt,
params = samplingFrom(cfg.thinker),
onToken = null,
tag = "THINKER"
)
val dt = System.currentTimeMillis() - tStart
// Tronque les bullets à ~400 chars (≈ 100 tokens) pour garantir que
// le prompt Speaker reste sous le budget prefill du .pte
// (context_len - ar_len ≈ 384 tokens). Sans cap, un Thinker qui
// "rame" (Qwen3-4B générique, pas un Guard spécialisé) peut produire
// 200-300 tokens de bullets et faire crasher Speaker au prefill.
val rawBullets = result.text
val capped = if (rawBullets.length > 250) {
log("[THINKER] WARN: bullets ${rawBullets.length} chars → truncated to 250")
rawBullets.take(250)
} else rawBullets
log("[THINKER] bullets (${dt}ms, ${result.tokenCount}tok ${"%.1f".format(result.tokensPerSecond)}t/s):\n$capped")
metrics.mark("THINKER.end", "wall=${dt}ms tok=${result.tokenCount} chars=${capped.length}")
capped
} else ""
} catch (e: Exception) {
log("[THINKER] failed: ${e.message} — fallback mono Speaker")
""
}
// Speaker : user prompt = juste le message patient. Les bullets vont
// dans le system prompt (override) pour empêcher le Speaker de les
// régurgiter dans sa sortie (vu en pratique : le 4B reformule "Analyse
// clinique : ...." comme partie de sa réponse, lue par le TTS).
//
// Sécurité prefill budget : context_len(512)-ar_len(128) = 384 tok max.
// SYS_KAZEIA + intro analyse ≈ 250 tok, bullets ≤ 250 chars (~65 tok),
// boilerplate ~15 tok → reste ~54 tok pour le message patient.
// À 4 chars/tok ≈ 200 chars max user message safe avec cascade.
// Si message plus long → on tombe en mono Speaker (pas de bullets).
val speakerSystemPrompt: String? = if (cascadeBullets.isNotBlank() && patientMessage.length <= 200) {
com.kazeia.config.ConfigStore.DEFAULT_SPEAKER_PROMPT +
"\n\nIndices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement à orienter le ton de ta réponse) :\n" +
cascadeBullets
} else {
if (cascadeBullets.isNotBlank()) {
log("[CASCADE] patientMessage ${patientMessage.length} chars > 200 → fallback mono Speaker (skip bullets)")
}
null
}
var prompt = promptBuilder.build(
message = patientMessage,
history = _messages.value
)
// Injection RAG : si activé + embedder prêt + contexte au-dessus du seuil,
// on préfixe le bloc CONTEXTE au prompt patient. Le seuil dans Rag.retrieve
// évite d'injecter du hors-sujet (garde-fou clinique). Le RAG ne porte
// JAMAIS la gestion de crise.
rag?.takeIf { it.isReady }?.let { r ->
try {
// Seuil + top-k pilotés depuis l'admin (défauts e5 : 0.82 / 3).
r.retrieve(patientMessage, k = runtimeConfig.ragTopK, threshold = runtimeConfig.ragThreshold)?.let { ctx ->
prompt = "$ctx\n\n$prompt"
log("[RAG] contexte injecté (${ctx.length} chars)")
}
} catch (e: Exception) { log("[RAG] retrieve échec: ${e.message}") }
}
// TTS = CosyVoice (CPU) : PAS de streaming LLM→TTS. LLM decode et synthèse
// CosyVoice sont tous deux CPU-bound (saturent les cœurs) ; les chevaucher
// étrangle le LLM (mesuré : 17→0,55 tok/s, tour 1,1s→41s). On laisse le LLM
// finir vite (session ~1,1s) PUIS pipeline.speakText (batch) qui streame déjà
// phrase-par-phrase EN INTERNE (synthèse N+1 pendant lecture N, sans contention).
// Bulle vide + signal Thinking sur le visualizer : la couleur de l'orbe
// pulse pendant que le LLM réfléchit, plus de "..." textuels parasites.
// L'écran reveal mot-par-mot s'enchaîne quand le premier audio joue.
val bubble = ChatMessage(role = ChatMessage.Role.KAZEIA, text = "")
val revealScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default)
var revealedSoFar = ""
val revealJobs = mutableListOf<kotlinx.coroutines.Job>()
val firstSegmentSeen = java.util.concurrent.atomic.AtomicBoolean(false)
// Annonce visuelle "thinking" : couleur orbe en pulsation jusqu'au 1er audio.
_visualizerSignal.value = VisualizerSignal.Thinking
// No-op job kept for compat with downstream typingJob.cancel() calls.
val typingJob = revealScope.launch { /* visual handled by orb */ }
var segCounter = 0
val onSegPlay: (String, Long, FloatArray, Array<FloatArray>) -> Unit = onSeg@{
sentence, durationMs, envelope, spectrogram ->
val segIdx = segCounter++
if (firstSegmentSeen.compareAndSet(false, true)) {
try { typingJob.cancel() } catch (_: Exception) {}
updateMessageText(bubble.id, "")
metrics.mark("PIPELINE.first_audio",
"seg=$segIdx dur=${durationMs}ms")
_pipelineState.value = PipelineState.Speaking
// 1er son audible → on déverrouille l'interruption (l'usager peut
// désormais couper la réponse). Cf. producingBeforeAudio().
audiblePlayback = true
}
metrics.mark("TTS.seg.play_start",
"seg=$segIdx sentence=\"${sentence.take(40)}\" dur=${durationMs}ms")
val durCopy = durationMs
val segCopy = segIdx
kotlinx.coroutines.GlobalScope.launch {
kotlinx.coroutines.delay(durCopy)
metrics.mark("TTS.seg.play_end",
"seg=$segCopy reported_dur=${durCopy}ms")
}
_visualizerSignal.value =
VisualizerSignal.Speaking(envelope, spectrogram, durationMs)
val prefix = revealedSoFar
val words = sentence.split(Regex("\\s+")).filter { it.isNotBlank() }
revealedSoFar =
if (prefix.isEmpty()) sentence
else "$prefix $sentence"
if (words.isEmpty()) return@onSeg
val perWordMs = (durationMs / words.size).coerceAtLeast(40L)
val job = revealScope.launch {
val sb = StringBuilder(prefix)
if (prefix.isNotEmpty()) sb.append(' ')
sb.append(words[0])
updateMessageText(bubble.id, sb.toString())
for (i in 1 until words.size) {
kotlinx.coroutines.delay(perWordMs)
sb.append(' ').append(words[i])
updateMessageText(bubble.id, sb.toString())
}
}
revealJobs.add(job)
}
// Bubble + TTS session open now so the animated typing dots appear
// as soon as the user finishes speaking, covering the LLM's TTFT.
addMessage(bubble)
val llmStreamStart = System.currentTimeMillis()
// TTS coupé (llmOnly) → pas de session ; la bulle se remplit token par token.
// CosyVoice (CPU) : pas de streaming LLM→TTS (contention CPU). Le LLM décode
// vite, puis synthèse batch via pipeline.speakText (qui streame déjà
// phrase-par-phrase EN INTERNE). ttsStreamer reste donc null.
val ttsStreamer: com.kazeia.tts.SentenceStreamer? = null
// In LLM_ONLY_MODE, kill the typing dots as soon as the first token
// arrives so the bubble starts revealing text immediately. Outside
// this mode, the dots continue until the first TTS segment plays.
val killDotsOnFirstToken = llmOnly
try {
stoppingCriteria.reset()
val responseBuilder = StringBuilder()
_aiWorkload.value = _aiWorkload.value.copy(llmActive = true)
metrics.mark("LLM.start",
"engine=${llm::class.simpleName} prompt_chars=${prompt.length}")
var firstTokenSeen = false
var firstTokenAtMs = -1L
val tokenIdx = java.util.concurrent.atomic.AtomicInteger(0)
val onTokenLambda: (String) -> Boolean = { token ->
if (!firstTokenSeen) {
firstTokenSeen = true
firstTokenAtMs = System.currentTimeMillis() - llmStreamStart
metrics.mark("LLM.first_token", "piece=\"${token.take(16).replace("\n", "\\n")}\"")
if (killDotsOnFirstToken && firstSegmentSeen.compareAndSet(false, true)) {
try { typingJob.cancel() } catch (_: Exception) {}
updateMessageText(bubble.id, "")
}
}
responseBuilder.append(token)
ttsStreamer?.append(token)
// TTS coupé : la bulle se met à jour à chaque token, l'usager
// voit la réponse s'écrire progressivement en temps réel.
if (llmOnly) {
updateMessageText(bubble.id, responseBuilder.toString())
}
val ti = tokenIdx.getAndIncrement()
val dt = System.currentTimeMillis() - llmStreamStart
val safe = token.replace("\n", "\\n").replace("\r", "")
log("[LLM.tok] #$ti +${dt}ms '$safe'")
// Suppress TokenGenerated state updates once the first
// segment plays — the state stays Speaking so the mic
// loop reliably drops frames during TTS playback.
if (!firstSegmentSeen.get()) {
_pipelineState.value = PipelineState.TokenGenerated(
token = token,
fullText = responseBuilder.toString()
)
}
!stoppingCriteria.shouldStop(responseBuilder.toString())
}
// Sampling Speaker depuis la config (preset admin). maxTokens honoré par
// le moteur ; les autres knobs prendront effet avec l'API sampling natif.
val sParams = samplingFrom(cfg.speaker)
// generateWithSystem propage le prompt système Speaker par tour (bullets
// cascade ou hot-reload admin). UnifiedLlmAdapter le gère pour GGUF
// (mono-tour si override) comme pour .pte.
val llmRef = llm
val speakerPromptToUse = speakerSystemPrompt ?: cfg.speaker.systemPrompt
val result = if (llmRef is com.kazeia.llm.UnifiedLlmAdapter) {
llmRef.generateWithSystem(
prompt = prompt,
systemPromptOverride = speakerPromptToUse,
params = sParams,
onToken = onTokenLambda,
tag = "SPEAKER"
)
} else {
llmRef.generate(prompt = prompt, params = sParams, onToken = onTokenLambda)
}
metrics.mark("LLM.end",
"tokens=${result.tokenCount} wall=${result.timeMs}ms tps=${"%.2f".format(result.tokensPerSecond)}")
_aiWorkload.value = _aiWorkload.value.copy(llmActive = false)
val responseText = result.text.trim()
lastTurnOutput = responseText // encart « dernier tour »
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()) {
if (llmOnly) {
// TTS coupé — la bulle a été remplie token par token dans
// onToken ; on finalise avec la réponse nettoyée.
updateMessageText(bubble.id, responseText)
metrics.end("llm_only")
} else {
// CosyVoice : synthèse batch (speakText streame en interne).
_pipelineState.value = PipelineState.Speaking
pipeline.speakText(responseText) { sentence, durationMs, envelope, spectrogram ->
onSegPlay(sentence, durationMs, envelope, spectrogram)
}
revealJobs.forEach { try { it.join() } catch (_: Exception) {} }
updateMessageText(bubble.id, responseText)
metrics.end("ok")
}
// Historique : tour Kazeia persisté avec TTFT + durée LLM.
logConversationTurn(
role = Role.KAZEIA,
text = responseText,
ttftMs = firstTokenAtMs.takeIf { it >= 0 },
totalMs = result.timeMs
)
} else {
// Empty response — drop the placeholder bubble.
_messages.value = _messages.value.filter { it.id != bubble.id }
metrics.end("empty_response")
}
} catch (e: Exception) {
_aiWorkload.value = _aiWorkload.value.copy(llmActive = false)
log("ERROR: LLM generation error: ${e.message}")
metrics.mark("LLM.error", "msg=\"${e.message?.take(80) ?: ""}\"")
metrics.end("error")
_pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM")
addMessage(ChatMessage(
role = ChatMessage.Role.SYSTEM,
text = "Erreur lors de la génération: ${e.message}"
))
} finally {
firstSegmentSeen.set(true)
audiblePlayback = false // fin de tour : ré-verrouille pour le tour suivant
try { typingJob.cancel() } catch (_: Exception) {}
_pipelineState.value = if (_isListening.value)
PipelineState.Listening else PipelineState.Idle
// L'orbe DOIT quitter Thinking/Speaking en fin de tour, y compris en
// écoute continue — sinon il reste figé coloré (« réfléchit » alors
// qu'il a fini). La boucle de capture réémettra Listening(micRms) au
// fil des frames si on écoute encore. (Bug #3, reset jadis conditionnel.)
_visualizerSignal.value = VisualizerSignal.Idle
}
}
private fun addMessage(message: ChatMessage) {
_messages.value = _messages.value + message
}
/** Replace the text of an existing message (identified by id) in the
* message list. Used by the progressive-reveal flow to grow a
* KAZEIA message word-by-word as TTS audio plays. */
private fun updateMessageText(id: Long, newText: String) {
val current = _messages.value
val idx = current.indexOfLast { it.id == id }
if (idx < 0) return
val m = current[idx]
_messages.value = current.toMutableList().also {
it[idx] = m.copy(text = newText)
}
}
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()
_isListening.value = false // pilote l'arrêt des boucles AudioRecord
configReloadReceiver?.let { try { unregisterReceiver(it) } catch (_: Exception) {} }
ragReloadReceiver?.let { try { unregisterReceiver(it) } catch (_: Exception) {} }
stopThinkerEngine()
try { llm.release() } catch (_: Exception) {}
try { stt.release() } catch (_: Exception) {}
try { tts.release() } catch (_: Exception) {}
}
private fun registerConfigReloadReceiver() {
val r = object : android.content.BroadcastReceiver() {
override fun onReceive(c: android.content.Context?, i: Intent?) {
log("[CONFIG] RELOAD_CONFIG broadcast reçu — re-lecture du store")
com.kazeia.config.ConfigStore.get(this@KazeiaService).load()
}
}
configReloadReceiver = r
val filter = android.content.IntentFilter(
com.kazeia.telemetry.KazeiaTelemetryProvider.ACTION_RELOAD_CONFIG
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(r, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(r, filter)
}
}
/** Construit l'instance RAG (embedder e5 + index) si ragEnabled. Pose RagHolder. */
private fun buildRag(): com.kazeia.rag.Rag? = synchronized(ragLock) {
if (!runtimeConfig.ragEnabled) { log("[RAG] désactivé (config)"); com.kazeia.rag.RagHolder.instance = null; rag = null; return null }
// Idempotent : si une instance est déjà en place (course onCreate/toggle),
// ne pas en recréer une seconde (sinon 2e contexte natif e5 → fuite).
rag?.let { log("[RAG] déjà actif, build ignoré"); return it }
return try {
val embModel = "${KazeiaApplication.MODELS_DIR}/embed-e5-small.gguf"
val emb = com.kazeia.rag.EngineEmbedder(embModel, nThreads = 4, pooling = -1,
queryPrefix = "query: ", passagePrefix = "passage: ", log = { m -> log(m) })
com.kazeia.rag.Rag(this@KazeiaService, emb, "e5-small") { m -> log(m) }.also {
// Synchro corpus fichiers → base à CHAQUE build (idempotent, n'embed
// que le delta) : couvre le 1er démarrage ET les MAJ OTA du composant
// rag_corpus (nouveaux fichiers ingérés au redémarrage suivant).
if (it.isReady)
it.syncDir(com.kazeia.KazeiaPaths.ragCorpusDir)
it.reingestMissing() // embed les docs admin ajoutés hors-ligne
rag = it
com.kazeia.rag.RagHolder.instance = it
log("[RAG] activé (ready=${it.isReady}, chunks=${it.count()})")
}
} catch (e: Throwable) { log("[RAG] init échec: ${e.message}"); com.kazeia.rag.RagHolder.instance = null; rag = null; null }
}
private fun teardownRag() = synchronized(ragLock) {
rag?.let { try { it.close() } catch (_: Exception) {} }
rag = null
com.kazeia.rag.RagHolder.instance = null
log("[RAG] teardown (désactivé à chaud)")
}
private fun registerRagReloadReceiver() {
val r = object : android.content.BroadcastReceiver() {
override fun onReceive(c: android.content.Context?, i: Intent?) {
val r0 = rag ?: run { log("[RAG] RELOAD reçu mais RAG inactif (doc enregistré, indexé à l'activation)"); return }
if (!r0.isReady) { log("[RAG] RELOAD reçu mais embedder non prêt"); return }
serviceScope.launch {
val n = r0.reingestMissing()
log("[RAG] RELOAD: réindexation ($n chunks, corpus=${r0.count()})")
}
}
}
ragReloadReceiver = r
val filter = android.content.IntentFilter(
com.kazeia.telemetry.KazeiaTelemetryProvider.ACTION_RELOAD_RAG
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(r, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(r, filter)
}
}
/**
* Appliqué quand l'admin app (via ContentProvider.update) ou la
* relecture broadcast écrasent la config.
*
* - Prompts : pris en compte au prochain tour (l'engine lit la config
* à chaque generate). Pas de reload.
* - Speaker model_id changé → restart complet du process Kazeia.
* L'in-process release+recreate échoue à la 2ᵉ exécution
* (`Failed to load llm runner: [1]`) à cause d'un bug singleton dans
* `QnnBackendUnifiedRegistry` (cf project_kazeia_reset_context_bug).
* Un restart propre relit la config et instancie l'engine avec le
* nouveau .pte sans état QNN résiduel.
* - Cascade toggle ON → Thinker chargé au prochain tour ; OFF →
* libération immédiate.
* - Thinker model_id changé en cascade ON → release, sera lazy-loadé.
*/
/** ModelConfig (preset admin) → SamplingParams pour la génération. ⚠ seul
* maxNewTokens est honoré par le moteur natif actuel ; le reste est prêt et
* prendra effet dès que le JNI expose le sampling (docs/SAMPLING_ENGINE_SPEC). */
private fun samplingFrom(m: com.kazeia.config.ConfigStore.ModelConfig) =
com.kazeia.core.SamplingParams(
maxNewTokens = m.maxTokens,
temperature = m.temperature,
topP = m.topP,
topK = m.topK,
repetitionPenalty = m.repeatPenalty,
presencePenalty = m.presencePenalty,
frequencyPenalty = m.frequencyPenalty
)
private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) {
val old = runtimeConfig
runtimeConfig = new
if (new.debugEnabled != old.debugEnabled) {
_debugMode.value = new.debugEnabled // affiche/masque le bouton debug côté patient
log("[CONFIG] debug ${old.debugEnabled}${new.debugEnabled}")
}
log("[CONFIG] applied : cascade ${old.cascadeEnabled}${new.cascadeEnabled}, " +
"speaker ${old.speaker.modelId}${new.speaker.modelId}, " +
"thinker ${old.thinker.modelId}${new.thinker.modelId}")
// Speaker model swap → full restart (contourne bug QNN double-swap)
if (new.speaker.modelId != old.speaker.modelId) {
log("[CONFIG] Speaker model changé → restart Kazeia dans 1s pour state QNN propre")
scheduleSelfRestart()
return // pas la peine de traiter le Thinker, on va redémarrer
}
// Thinker lifecycle suivant le toggle + le model_id
if (!new.cascadeEnabled && old.cascadeEnabled) {
log("[CONFIG] cascade OFF → libération Thinker")
stopThinkerEngine()
} else if (new.cascadeEnabled && old.cascadeEnabled &&
new.thinker.modelId != old.thinker.modelId) {
log("[CONFIG] Thinker model changé → release + lazy reload au prochain tour")
stopThinkerEngine()
}
// ON après OFF : le Thinker sera lazy-loadé au prochain tour via ensureThinkerEngine()
// Toggle RAG à chaud (build/teardown sans restart). Le seuil/top-k sont
// lus au moment du retrieve → pas de rebuild nécessaire pour eux.
if (new.ragEnabled != old.ragEnabled) {
serviceScope.launch {
if (new.ragEnabled) rag = buildRag() else teardownRag()
}
}
}
/**
* Programme un redémarrage propre du process Kazeia : AlarmManager
* relance ChatActivity dans 1 seconde, puis on tue le process actuel.
* Au cold start, ConfigStore.load() lit la nouvelle config et le LLM
* est instancié avec le bon .pte — sans état QNN résiduel.
*/
private fun scheduleSelfRestart() {
val ctx = applicationContext
val launchIntent = Intent(ctx, com.kazeia.ui.SplashActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
val pi = android.app.PendingIntent.getActivity(
ctx, 0, launchIntent,
android.app.PendingIntent.FLAG_IMMUTABLE or
android.app.PendingIntent.FLAG_CANCEL_CURRENT
)
val am = ctx.getSystemService(android.app.AlarmManager::class.java)
am?.set(
android.app.AlarmManager.RTC,
System.currentTimeMillis() + 1000,
pi
)
log("[CONFIG] self-restart programmé (alarm +1s) — process killé dans 500ms")
// Petit délai pour que la réponse ContentProvider.update parte avant
// le suicide du process.
serviceScope.launch {
kotlinx.coroutines.delay(500)
android.os.Process.killProcess(android.os.Process.myPid())
}
}
}