1947 lines
100 KiB
Kotlin
1947 lines
100 KiB
Kotlin
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())
|
||
}
|
||
}
|
||
}
|