diff --git a/kazeia-android/app/src/main/java/com/kazeia/config/ModelRegistry.kt b/kazeia-android/app/src/main/java/com/kazeia/config/ModelRegistry.kt new file mode 100644 index 0000000..92c11d9 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/config/ModelRegistry.kt @@ -0,0 +1,135 @@ +package com.kazeia.config + +import java.io.File + +/** + * Catalogue figé des modèles LLM qu'on sait charger via ExecuTorch QNN HTP. + * Filtrer par `availableOnDisk()` pour ne proposer dans l'UI admin que ceux + * réellement présents sur la tablette. + * + * L'admin app lit cette liste via le ContentProvider URI `/models` ; + * KazeiaService la consulte pour résoudre un `model_id` → (pte, tokenizer). + */ +object ModelRegistry { + + // Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et + // sur tablette dev, stockage externe app sur tablette de production). + private val ET_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR + + // Racine GGUF (moteur kazeia-engine "lib"). Les GGUF (q35-lmq4, qwen2.5-7b…) + // vivent sous MODELS_DIR, pas LLM_DIR. + private val GGUF_DIR: String get() = com.kazeia.KazeiaApplication.MODELS_DIR + + enum class Role { SPEAKER, THINKER, BOTH } + + // PTE = runner ExecuTorch QNN HTP ; GGUF = moteur kazeia-engine "lib" (CPU). + enum class Backend { PTE, GGUF } + + // Template ChatML du modèle. QWEN35_THINKOFF injecte le bloc + // (thinking-off déterministe Qwen3.5). PLAIN_CHATML = ChatML standard sans + // (Qwen2.5 et autres modèles dense sans raisonnement). + enum class ChatTemplate { QWEN35_THINKOFF, PLAIN_CHATML } + + data class ModelInfo( + val id: String, + val displayName: String, + val pteFile: String, + val tokenizerFile: String, + val role: Role, + val sizeMb: Int, + val maxSeqLen: Int, + val notes: String = "", + val backend: Backend = Backend.PTE, + // Nom du fichier GGUF (sous GGUF_DIR) quand backend = GGUF. Le tokenizer + // est embarqué dans le GGUF, pas de fichier séparé. + val ggufFile: String = "", + val chatTemplate: ChatTemplate = ChatTemplate.QWEN35_THINKOFF + ) { + fun ptePath() = "$ET_DIR/$pteFile" + fun tokenizerPath() = "$ET_DIR/$tokenizerFile" + fun ggufPath() = "$GGUF_DIR/$ggufFile" + fun fileExists(): Boolean = when (backend) { + Backend.PTE -> File(ptePath()).exists() + Backend.GGUF -> File(ggufPath()).exists() + } + } + + val ALL: List = listOf( + ModelInfo( + id = "qwen3-4b-seq512", + displayName = "Qwen3-4B (seq=512)", + pteFile = "hybrid_llama_qnn_4b.pte", + tokenizerFile = "tokenizer.json", + role = Role.SPEAKER, + sizeMb = 3000, + maxSeqLen = 512, + notes = "Speaker historique, budget contexte serré" + ), + ModelInfo( + id = "qwen3-4b-seq1024", + displayName = "Qwen3-4B (seq=1024) ★", + pteFile = "hybrid_llama_qnn_4b_seq1024.pte", + tokenizerFile = "tokenizer.json", + role = Role.SPEAKER, + sizeMb = 3000, + maxSeqLen = 1024, + notes = "Speaker recommandé — production stable 2026-05-14" + ), + ModelInfo( + id = "qwen3.5-4b", + displayName = "Qwen3.5-4B (GGUF) ★", + pteFile = "", + tokenizerFile = "", + role = Role.SPEAKER, + sizeMb = 2380, + maxSeqLen = 4096, + notes = "Speaker GGUF par défaut du moteur lib (q35-lmq4). Thinking-off Qwen3.5.", + backend = Backend.GGUF, + ggufFile = "q35-lmq4.gguf", + chatTemplate = ChatTemplate.QWEN35_THINKOFF + ), + ModelInfo( + id = "qwen2.5-7b-instruct", + displayName = "Qwen2.5-7B Instruct (GGUF)", + pteFile = "", + tokenizerFile = "", + role = Role.SPEAKER, + sizeMb = 4683, + maxSeqLen = 4096, + notes = "Speaker GGUF dense (arch qwen2) via moteur lib CPU. Q4_K_M ~4.7 GB.", + backend = Backend.GGUF, + ggufFile = "Qwen2.5-7B-Instruct-Q4_K_M.gguf", + chatTemplate = ChatTemplate.PLAIN_CHATML + ), + ModelInfo( + id = "guard06b", + displayName = "Qwen3Guard 0.6B", + pteFile = "hybrid_llama_qnn_guard06b.pte", + tokenizerFile = "tokenizer_guard06b.json", + role = Role.THINKER, + sizeMb = 672, + maxSeqLen = 512, + notes = "Thinker léger, ION ~700 MB, cascade safe" + ), + ModelInfo( + id = "guard4b", + displayName = "Qwen3Guard 4B", + pteFile = "hybrid_llama_qnn_guard4b.pte", + tokenizerFile = "tokenizer_guard.json", + role = Role.THINKER, + sizeMb = 3000, + maxSeqLen = 512, + notes = "Thinker analytique. ⚠ Cascade Guard-4B + Speaker-4B = risque OOM." + ) + ) + + fun availableOnDisk(): List = ALL.filter { it.fileExists() } + + fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id } + + fun defaultSpeaker(): ModelInfo = byId("qwen3-4b-seq1024") + ?: ALL.first { it.role == Role.SPEAKER && it.fileExists() } + + fun defaultThinker(): ModelInfo = byId("guard06b") + ?: ALL.first { it.role == Role.THINKER && it.fileExists() } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmAdapter.kt b/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmAdapter.kt new file mode 100644 index 0000000..083de71 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/llm/EngineLlmAdapter.kt @@ -0,0 +1,69 @@ +package com.kazeia.llm + +import com.kazeia.core.GenerationResult +import com.kazeia.core.LlmConfig +import com.kazeia.core.LlmEngine +import com.kazeia.core.SamplingParams +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Adapter qui expose `com.kazeia.llm.EngineLlmEngine` (façade dist Kazeia-Engine, + * GGUF Qwen3.5/Qwen3 via libkazeia_engine.so + libllama fork ql + Hexagon) + * derrière l'interface prod `com.kazeia.core.LlmEngine`. Sert au swap réversible + * piloté par le flag config `llmEngine` ("prod" = ExecuTorch .pte | "lib" = engine GGUF). + * + * Pré-requis runtime : `libllama.so` fork ql + `libggml*.so` cohérents dans jniLibs. + * Si la chaîne libllama est encore en legacy (f86d), `System.loadLibrary("kazeia_engine")` + * jette UnsatisfiedLinkError au 1er accès à `EngineJni` → KazeiaService doit catch + * et tomber en repli sur le path prod. + * + * Modèle par défaut : Qwen3.5-4B `q35-lmq4.gguf` (upgrade vs Qwen3 dense en prod). + */ +class EngineLlmAdapter( + private val modelPath: String, + private val systemPrompt: String, + private val ctxLen: Int = 4096, + private val nThreads: Int = 6, + // true = ChatML standard sans (Qwen2.5…) via generateRaw ; + // false = bloc thinking-off Qwen3.5 injecté côté natif (generate). + private val plainChatml: Boolean = false, + private val onLog: ((String) -> Unit)? = null +) : LlmEngine { + private var engine: EngineLlmEngine? = null + + override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) { + if (engine != null) return@withContext + val t0 = System.currentTimeMillis() + engine = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads) + onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads}") + } + + override fun isLoaded(): Boolean = engine != null + + override suspend fun generate( + prompt: String, + params: SamplingParams, + onToken: ((String) -> Boolean)? + ): GenerationResult = withContext(Dispatchers.IO) { + val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded") + val t0 = System.currentTimeMillis() + val out = if (plainChatml) { + // ChatML standard, sans bloc (le natif generate l'injecterait). + val p = buildString { + append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n") + append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n") + append("<|im_start|>assistant\n") + } + e.generateRaw(p, params.maxNewTokens).trim() + } else { + e.generate(systemPrompt, prompt, params.maxNewTokens).trim() + } + val ms = System.currentTimeMillis() - t0 + onToken?.invoke(out) + val n = out.split(Regex("\\s+")).size + GenerationResult(out, n, ms, if (ms > 0) n * 1000f / ms else 0f) + } + + override fun release() { engine?.release(); engine = null } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt index f39e3e7..0bee524 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt @@ -15,7 +15,6 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.kazeia.KazeiaApplication import com.kazeia.R -import com.kazeia.audio.AudioCaptureManager import com.kazeia.audio.AudioPlaybackManager import com.kazeia.conversation.ConversationManager import com.kazeia.conversation.PromptBuilder @@ -23,35 +22,118 @@ import com.kazeia.conversation.StoppingCriteria import com.kazeia.core.* import com.kazeia.llm.ExecuTorchLlmEngine import com.kazeia.llm.GenieLlmEngine -import com.kazeia.stt.AndroidSttEngine +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.stt.WhisperSttEngine -import com.kazeia.tts.AndroidTtsEngine -import com.kazeia.tts.ChatterboxTtsEngine import com.kazeia.ui.ChatActivity -import com.kazeia.vad.SileroVadEngine 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 + // 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 : +- besoin : +- approche : +- axe : +Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think""" } private lateinit var llm: LlmEngine - private lateinit var stt: SttEngine - private lateinit var tts: TtsEngine - private lateinit var vad: VadEngine + // Cascade Option E v2 : 2 ExecuTorchLlmEngine cohabitants in-process. + // - llm = Speaker Qwen3-4B (.pte ~3.3 GB ION, ~4.4 GB total) + // - thinkerEngine = Guard-0.6B (.pte ~700 MB ION) lazy-loaded au 1er PTT + // Total ION ~5.1 GB, marge OK pour TTS pic. QnnBackendUnifiedRegistry + // singleton partage le QnnBackendBundle (handle fastrpc DSP) entre les 2 + // contextes — pas de collision DSP, pas de subprocess. Cf + // project_kazeia_cascade_option_e dans la mémoire persistante. + private var thinkerEngine: com.kazeia.llm.ExecuTorchLlmEngine? = 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 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") + } + + private suspend fun reloadLlmAfterTurn() = llmMutex.withLock { + val ll = llm as? com.kazeia.llm.ExecuTorchLlmEngine ?: return@withLock + if (!ll.isLoaded()) return@withLock + val t0 = System.currentTimeMillis() + log("[LLM] reload pour reset cur_pos (état propre prochain tour)…") + ll.reloadAfterTurn() + log("[LLM] reloaded in ${System.currentTimeMillis() - t0}ms") + } + private lateinit var tts: TtsEngine - private lateinit var audioCapture: AudioCaptureManager 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) private lateinit var voiceCommands: com.kazeia.conversation.VoiceCommandProcessor // New modular pipeline @@ -92,6 +174,8 @@ class KazeiaService : Service() { 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, @@ -119,6 +203,60 @@ class KazeiaService : Service() { private val _loadingState = MutableStateFlow(LoadingState()) val loadingState: StateFlow = _loadingState + /** + * Cascade E v2 : crée la 2ᵉ ExecuTorchLlmEngine (Thinker = Guard-0.6B) dans + * le MÊME process app. Lazy au 1er PTT (~1-2 s de load 0.6B). Le + * QnnBackendUnifiedRegistry singleton réutilise le QnnBackendBundle du + * Speaker → un seul handle fastrpc DSP, 2 contextes/graphes coexistent. + */ + 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() + val e = com.kazeia.llm.ExecuTorchLlmEngine( + context = this@KazeiaService, + onLog = { msg -> log("[THINKER] $msg") }, + modelPath = info.ptePath(), + tokenizerPath = info.tokenizerPath(), + systemPrompt = cfg.thinker.systemPrompt, + temperature = cfg.thinker.temperature, + displayName = "${info.displayName} Thinker" + ) + 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()) @@ -157,10 +295,31 @@ class KazeiaService : Service() { // inter-token pause so we can observe the "sentence N plays // while sentence N+1 is generating" behaviour end-to-end. Used // in debug builds where the LLM is disabled (echo mode). - log("Stream LLM mock: '${text.take(60)}${if (text.length>60) "..." else ""}'") + // Optional companion extra `tts_voice` selects voice prefix file + // before generation (used by chantier ggml-cpu reference dump). + val voiceOverride = intent.getStringExtra("tts_voice") + log("Stream LLM mock: '${text.take(60)}${if (text.length>60) "..." else ""}'${voiceOverride?.let { " voice=$it" } ?: ""}") serviceScope.launch { try { - val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: return@launch + val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine + if (qwenTts == null) { + // Backend non-Qwen3 (ex. KazeiaTtsEngine lib) : pas de + // streaming par phrase, on synthétise le texte complet + // via l'interface et on joue. Sert au test E2E du wiring. + if (!::tts.isInitialized) { log("Stream mock: TTS not ready"); return@launch } + _pipelineState.value = PipelineState.Speaking + val tS = System.currentTimeMillis() + tts.synthesizeAndPlay(text, "fr", + onComplete = { + log("Stream mock (lib) done at ${System.currentTimeMillis() - tS}ms") + _pipelineState.value = PipelineState.Idle + }) + return@launch + } + if (voiceOverride != null) { + qwenTts.setVoice(voiceOverride) + log("Voice set to: $voiceOverride") + } qwenTts.startStreamingSession() val tStart = System.currentTimeMillis() var firstSentenceLogged = false @@ -400,9 +559,11 @@ class KazeiaService : Service() { intent?.getStringExtra("tts_text")?.let { text -> val savePath = intent.getStringExtra("save_wav") if (savePath != null) { - log("TTS generate WAV: '$text' → $savePath") + 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") @@ -442,11 +603,52 @@ class KazeiaService : Service() { 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}") } + } + } + 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() + store.addListener { new -> handleConfigChange(new) } + registerConfigReloadReceiver() + val hasMicPermission = ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED @@ -484,121 +686,107 @@ class KazeiaService : Service() { // Initialize engines val modelsDir = KazeiaApplication.MODELS_DIR - _loadingState.value = LoadingState(5, "VAD…") - vad = SileroVadEngine() - vad.load(this@KazeiaService) - log("VAD loaded") - _loadingState.value = LoadingState(10, "VAD OK") + // 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: try Qwen3-TTS (NPU Hexagon), fallback to Android TTS - _loadingState.value = LoadingState(15, "TTS Qwen3…") - try { + // TTS legacy gated par ttsEngine="prod". Si "lib" ou autre : + // skip l'init Qwen3TtsEngine (incompat ABI avec libllama fork-ql + // déployé pour LLM engine). Step B câblera l'adapter lib. + val ttsBackend = runtimeConfig.ttsEngine + if (ttsBackend == "lib") { + _loadingState.value = LoadingState(15, "TTS engine…") + val libTts = com.kazeia.tts.KazeiaTtsEngine(this@KazeiaService) { msg -> log("[TTS] $msg") } + libTts.load() + if (!libTts.isLoaded()) throw IllegalStateException("KazeiaTtsEngine load failed") + tts = libTts + log("TTS: KazeiaTtsEngine loaded (libkazeia_tts CPU-only)") + } else { + _loadingState.value = LoadingState(15, "TTS Qwen3…") val qwenTts = com.kazeia.tts.Qwen3TtsEngine(nativeLibDir, this@KazeiaService) { msg -> log("[TTS] $msg") } qwenTts.load("$modelsDir/qwen3-tts-npu") - if (qwenTts.isLoaded()) { - tts = qwenTts - log("TTS: Qwen3-TTS loaded (Hexagon NPU)") - } else { - tts = AndroidTtsEngine(this@KazeiaService) - tts.load(voiceId = "kazeia_fr") - log("TTS: Qwen3 failed, using Android TTS") - } - } catch (e: Exception) { - log("TTS: Qwen3 error: ${e.message}, using Android TTS") - tts = AndroidTtsEngine(this@KazeiaService) - tts.load(voiceId = "kazeia_fr") + if (!qwenTts.isLoaded()) throw IllegalStateException("Qwen3-TTS load failed (no fallback in frozen stack)") + tts = qwenTts + log("TTS: Qwen3-TTS loaded (Hexagon NPU + ggml-cpu)") } _loadingState.value = LoadingState(20, "TTS OK") - // STT: try NPU first, fallback to CPU - _loadingState.value = LoadingState(25, "STT Whisper…") - var sttLoaded = false + // 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)…") + // Backend STT pilotable via ConfigStore.sttEngine. Default "prod" = + // WhisperHybridEngine gelé. "lib" = KazeiaSttAdapter (libkazeia_stt.so). + val sttBackend = runtimeConfig.sttEngine + val npuStt: com.kazeia.core.SttEngine = when (sttBackend) { + "lib" -> com.kazeia.stt.KazeiaSttAdapter( + "$modelsDir/whisper-small-sm8750" + ) { msg -> log("[STT] $msg") } + else -> WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") } + } + sttModelPath = "$modelsDir/whisper-small-sm8750" + stt = npuStt + log("Whisper créé backend='$sttBackend' (lazy load au 1er PTT)") + addMessage(ChatMessage( + role = ChatMessage.Role.SYSTEM, + text = "STT: Whisper-Small NPU" + )) - // Try 1: Whisper NPU (Qualcomm ONNX QNN EP) + // 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 = Kazeia-Engine GGUF (llama.cpp upstream + Hexagon), Speaker + // Qwen3.5-9B prefill NPU / decode CPU. Plus de .pte (-30% RAM). + // Backend LLM pilotable via ConfigStore.llmEngine. Default "prod" = + // ExecuTorch .pte (Qwen3 dense). "lib" = EngineLlmAdapter (libkazeia_engine, + // GGUF Qwen3.5). Fallback gracieux sur prod si la lib échoue au load + // (libllama fork ql encore absente du jniLibs tant que swap coordonné non fait). + val llmBackend = runtimeConfig.llmEngine + _loadingState.value = LoadingState(50, "LLM backend=$llmBackend…") + val sp = com.kazeia.config.ModelRegistry.byId(runtimeConfig.speaker.modelId) ?: com.kazeia.config.ModelRegistry.defaultSpeaker() + val llmLib: com.kazeia.core.LlmEngine? = if (llmBackend == "lib") try { + // Si le Speaker sélectionné est un GGUF du registre (ex. Qwen2.5-7B), + // on le charge ; sinon fallback sur le GGUF moteur par défaut (q35-lmq4). + val gguf = if (sp.backend == com.kazeia.config.ModelRegistry.Backend.GGUF && sp.fileExists()) + sp.ggufPath() + else + "${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf" + val plain = sp.chatTemplate == com.kazeia.config.ModelRegistry.ChatTemplate.PLAIN_CHATML + log("[LLM] lib GGUF=$gguf (speaker=${sp.id}, plainChatml=$plain)") + com.kazeia.llm.EngineLlmAdapter(gguf, runtimeConfig.speaker.systemPrompt, + plainChatml = plain, onLog = { msg -> log(msg) }) + } catch (e: Throwable) { + log("[LLM] lib instantiation failed (${e.message}) — fallback .pte") + null + } else null + llm = llmLib ?: ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) }, + sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt, + runtimeConfig.speaker.temperature, sp.displayName) try { - val npuStt = WhisperHybridEngine(nativeLibDir) { msg -> log(msg) } - npuStt.load("$modelsDir/whisper-small-sm8750") - if (npuStt.isLoaded()) { - stt = npuStt - sttLoaded = true - log("Using Whisper ONNX") - addMessage(ChatMessage( - role = ChatMessage.Role.SYSTEM, - text = "STT: Whisper ONNX loaded" - )) - } + llm.load("", com.kazeia.core.LlmConfig()) + log("LLM créé backend='${if (llmLib != null) "lib" else "prod"}'") } catch (e: Exception) { - log("WARN: Whisper NPU failed: ${e.message}") - } - - // Try 2: whisper.cpp CPU - if (!sttLoaded) { - try { - val cpuStt = WhisperSttEngine() - cpuStt.load("$modelsDir/whisper-base") - if (cpuStt.isLoaded()) { - stt = cpuStt - sttLoaded = true - log("Using whisper.cpp (CPU)") - addMessage(ChatMessage( - role = ChatMessage.Role.SYSTEM, - text = "STT: Whisper-Base (CPU)" - )) - } - } catch (e: Exception) { - log("WARN: whisper.cpp failed: ${e.message}") - } - } - - if (!sttLoaded) { - stt = AndroidSttEngine(this@KazeiaService) - stt.load() - addMessage(ChatMessage( - role = ChatMessage.Role.SYSTEM, - text = "STT: Android natif" - )) - } - - // LLM backend selection : llama.cpp Qwen3.5-4B (new default for - // therapeutic conversation, measured ~11 tok/s on OPD2415 +57 % - // vs ExecuTorch path) or legacy ExecuTorch Qwen3-4B QNN. - // Override at runtime via `adb shell setprop debug.kazeia.llm_backend llamacpp`. - val backend = try { - val spClass = Class.forName("android.os.SystemProperties") - spClass.getMethod("get", String::class.java, String::class.java) - .invoke(null, "debug.kazeia.llm_backend", KazeiaApplication.LLM_BACKEND) as String - } catch (_: Throwable) { KazeiaApplication.LLM_BACKEND } - - _loadingState.value = LoadingState(50, "LLM $backend…") - when (backend) { - "llamacpp" -> { - val engine = com.kazeia.llm.LlamaCppLlmEngine() - llm = engine - try { - val gguf = "${KazeiaApplication.MODELS_DIR}/${KazeiaApplication.LLAMACPP_MODEL_FILENAME}" - engine.load(gguf, com.kazeia.core.LlmConfig(maxContextLength = 4096)) - log("LLM llama.cpp loaded: ${KazeiaApplication.LLAMACPP_MODEL_FILENAME}") - } catch (e: Exception) { - log("llama.cpp load failed: ${e.message} — falling back to echo mode") - } - } - else -> { - // "executorch" (default) — Qwen3-4B QNN on Hexagon V79. - llm = ExecuTorchLlmEngine(this@KazeiaService) { msg -> log(msg) } - try { - llm.load("${KazeiaApplication.MODELS_DIR}/qwen3-4b", com.kazeia.core.LlmConfig()) - } catch (e: Exception) { - log("LLM load failed: ${e.message} — falling back to echo mode") - } + log("LLM load failed (${e.message}) — fallback echo") + if (llmLib != null) { + // lib loadLibrary("kazeia_engine") a échoué (libllama fork ql absente) ; + // on retombe sur .pte pour ne pas perdre l'app + log("[LLM] retry on prod .pte") + llm = ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) }, + sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt, + runtimeConfig.speaker.temperature, sp.displayName) + try { llm.load("", com.kazeia.core.LlmConfig()) } catch (_: Exception) {} } } _loadingState.value = LoadingState(80, "Audio…") // Audio audioPlayback = AudioPlaybackManager() - audioCapture = AudioCaptureManager() // Conversation logic promptBuilder = PromptBuilder() @@ -635,6 +823,35 @@ class KazeiaService : Service() { _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) @@ -648,14 +865,57 @@ class KazeiaService : Service() { } } - fun setVoice(voicePath: String) { - val chatterbox = tts as? ChatterboxTtsEngine - if (chatterbox != null) { - chatterbox.setVoice(voicePath) - log("Voice set to: $voicePath") - return + // ─── 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 (basename du .wav), tracké pour le log de tour. + @Volatile private var currentVoiceName: String = "damien" + + /** + * 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}") + } } + } + + fun setVoice(voicePath: String) { + // Mémorise le nom de voix (basename sans extension) pour l'historique. + currentVoiceName = voicePath.substringAfterLast('/').substringBeforeLast('.') val qwen = tts as? com.kazeia.tts.Qwen3TtsEngine + val libTts = tts as? com.kazeia.tts.KazeiaTtsEngine if (qwen != null) { // Hot-swap prefix/suffix embeddings — no model reload. Takes // effect from the NEXT synthesized segment (current in-flight @@ -663,6 +923,10 @@ class KazeiaService : Service() { // are already in its closure). qwen.setVoice(voicePath) log("Voice set to: $voicePath") + } else if (libTts != null) { + // Engine lib : le natif dérive le x_vector du WAV (speaker encoder). + libTts.setVoice(voicePath) + log("Voice set to: $voicePath (lib)") } } @@ -697,7 +961,7 @@ class KazeiaService : Service() { } private fun startListening() { - log("startListening: stt=${stt::class.simpleName}, vad=${vad.isLoaded()}") + log("startListening: stt=${stt::class.simpleName} (VAD = RMS énergie inline)") _isListening.value = true _pipelineState.value = PipelineState.Listening @@ -827,9 +1091,22 @@ class KazeiaService : Service() { @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 + 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 @@ -880,12 +1157,19 @@ class KazeiaService : Service() { 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() var wasMuted = false + var lastBusyMs = 0L while (_isListening.value) { val read = recorder.read(frame, 0, frameSize) @@ -893,21 +1177,35 @@ class KazeiaService : Service() { val now = System.currentTimeMillis() - val isTtsSpeaking = _pipelineState.value is PipelineState.Speaking - val isTranscribing = _pipelineState.value is PipelineState.Transcribing + // 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 : 800 ms après la fin du Speaking pour + // absorber la reverb / queue MediaPlayer qui peut continuer + // à émettre du son alors que pipelineState est passé Idle. + if (machineBusy) { + lastBusyMs = nowMs + } + val inTailBuffer = (nowMs - lastBusyMs) < 800 - // Mute during TTS (mic button to interrupt) - if (isTtsSpeaking) { + 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 } - // Mute during transcription - if (isTranscribing) continue - if (wasMuted) { log("Mic unmuted"); wasMuted = false } // Compute RMS energy @@ -917,11 +1215,16 @@ class KazeiaService : Service() { // 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. The - // visualizer stays in Listening mode; it will swap - // to Speaking or Idle when pipelineState moves on. + // 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)) - _visualizerSignal.value = VisualizerSignal.Listening(rmsNorm) + 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) { @@ -930,8 +1233,13 @@ class KazeiaService : Service() { 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) { + if (isSpeech && !inWarmup) { silenceFrameCount = 0 speechFrameCount++ speechBuffer.add(frame.copyOf()) @@ -960,11 +1268,24 @@ class KazeiaService : Service() { 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(result.text.trim()) - log("Streaming result (${elapsed}ms): \"${result.text.trim()}\"") - // Show intermediate result - _pipelineState.value = PipelineState.Transcribed(result.text.trim()) + 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 + } } } } @@ -1050,6 +1371,34 @@ class KazeiaService : Service() { 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}") } @@ -1141,9 +1490,8 @@ class KazeiaService : Service() { private fun stopListening() { _isListening.value = false - val androidStt = stt as? AndroidSttEngine - androidStt?.stopListening() - audioCapture.stop() + // L'AudioRecord de startContinuousListening / startPushToTalkRecording + // est piloté par _isListening.value : la boucle while sort sur false. _pipelineState.value = PipelineState.Idle } @@ -1152,10 +1500,14 @@ class KazeiaService : Service() { 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") @@ -1176,6 +1528,15 @@ class KazeiaService : Service() { 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 { // Special test commands — cancel previous pipeline first if (text.trim().lowercase().let { it.startsWith("pipeline") || it.startsWith("!pipeline") || it.startsWith("\\!pipeline") || it == "go" }) { @@ -1225,6 +1586,15 @@ class KazeiaService : Service() { _pipelineState.value = PipelineState.Thinking 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 + // 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 @@ -1233,36 +1603,245 @@ class KazeiaService : Service() { log("Echo mode: '$patientMessage' → TTS (${tts::class.simpleName})") val echoResponse = patientMessage addMessage(ChatMessage(role = ChatMessage.Role.KAZEIA, text = echoResponse)) - pipeline.speakText(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 = com.kazeia.core.SamplingParams( + maxNewTokens = 128, temperature = cfg.thinker.temperature + ), + 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.llm.ExecuTorchLlmEngine.DEFAULT_SYSTEM_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 + } + val prompt = promptBuilder.build( message = patientMessage, history = _messages.value ) + // === Opt TTS-B: the TTS streaming session opens BEFORE llm.generate, + // so the first complete sentence emitted by the LLM (on terminal + // punctuation) is enqueued for synthesis while the LLM is still + // decoding the rest of the response. Saves ~LLM-wall-time of TTFA + // when the first sentence finishes before the LLM does (common for + // short conversational answers). Non-Qwen TTS backends that lack + // the enqueueSentence plumbing fall through to the legacy post-LLM + // pipeline.speakText batch call. + val qwen = tts as? com.kazeia.tts.Qwen3TtsEngine + // 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() + 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) -> 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 + } + 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() + val sentenceCounter = java.util.concurrent.atomic.AtomicInteger(0) + // TTS coupé (llmOnly) → pas de session de synthèse ouverte ; la bulle + // se remplit token par token dans onToken (voir plus bas). + val ttsStreamer: com.kazeia.tts.SentenceStreamer? = + if (qwen != null && !llmOnly) { + qwen.onSegmentPlaying = onSegPlay + qwen.startStreamingSession() + com.kazeia.tts.SentenceStreamer { raw -> + val sIdx = sentenceCounter.getAndIncrement() + val dt = System.currentTimeMillis() - llmStreamStart + val spoken = pipeline.stripNonSpeakable(raw).trim() + log("[LLM.sentence] #$sIdx +${dt}ms → '$spoken'") + if (spoken.isNotEmpty()) qwen.enqueueSentence(spoken) + } + } else 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) - val result = llm.generate( - prompt = prompt, - params = SamplingParams( - maxNewTokens = 450, - temperature = conversationManager.currentTemperature() - ), - onToken = { token -> - responseBuilder.append(token) + 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()) } + !stoppingCriteria.shouldStop(responseBuilder.toString()) + } + val sParams = SamplingParams( + maxNewTokens = 450, + temperature = conversationManager.currentTemperature() ) + // Si bullets cascade dispo → injection dans system prompt via + // generateWithSystem (cast vers ExecuTorchLlmEngine). Sinon + // generateWithSystem aussi pour propager le prompt système Speaker + // de la config runtime (hot-reload depuis admin app). + val llmRef = llm + val speakerPromptToUse = speakerSystemPrompt ?: cfg.speaker.systemPrompt + val result = if (llmRef is com.kazeia.llm.ExecuTorchLlmEngine) { + 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() @@ -1272,109 +1851,61 @@ class KazeiaService : Service() { log("LLM stats: ${result.tokenCount} tokens in ${result.timeMs}ms (${result.tokensPerSecond} tok/s)") if (responseText.isNotEmpty()) { - // Mark the pipeline as Speaking for the duration of TTS so - // the continuous-listening mic loop drops frames and we - // don't feed our own speaker output back into STT. - _pipelineState.value = PipelineState.Speaking - // Create a KAZEIA bubble up-front. Until the first TTS - // segment actually starts playing the bubble shows an - // animated "." → ".." → "..." typing indicator so the - // user knows Kazeia is thinking/synthesising; once the - // first segment plays the dots are cleared and the - // per-sentence word reveal takes over. - val bubble = ChatMessage(role = ChatMessage.Role.KAZEIA, text = ".") - addMessage(bubble) - val revealScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default) - var revealedSoFar = "" - val revealJobs = mutableListOf() - val firstSegmentSeen = java.util.concurrent.atomic.AtomicBoolean(false) - val typingJob = revealScope.launch { - var tick = 0 - while (!firstSegmentSeen.get()) { - val dots = ".".repeat(1 + (tick % 3)) // . → .. → ... - updateMessageText(bubble.id, dots) - tick++ - kotlinx.coroutines.delay(400) - } - } - try { - pipeline.speakText(responseText) { sentence, durationMs, envelope, spectrogram -> - // First segment: stop the typing indicator and - // reset the bubble to empty so the word reveal - // doesn't collide with the dots. - if (firstSegmentSeen.compareAndSet(false, true)) { - try { typingJob.cancel() } catch (_: Exception) {} - updateMessageText(bubble.id, "") - } - // Push the envelope + spectrogram to the - // visualizer at the same moment the MediaPlayer - // starts playing so the orb reacts to this - // segment's actual energy and the in-sphere - // spectrum bars match the audio content. - _visualizerSignal.value = - VisualizerSignal.Speaking(envelope, spectrogram, durationMs) - // Start a coroutine that appends one word at a time - // over the segment's audio duration. Words are - // separated on whitespace; punctuation rides with - // the trailing word. The prefix (= text already - // revealed from previous sentences) carries over so - // earlier sentences stay on screen. - val prefix = revealedSoFar - val words = sentence.split(Regex("\\s+")).filter { it.isNotBlank() } - revealedSoFar = - if (prefix.isEmpty()) sentence - else "$prefix $sentence" - if (words.isEmpty()) return@speakText - val perWordMs = (durationMs / words.size).coerceAtLeast(40L) - val job = revealScope.launch { - val sb = StringBuilder(prefix) - if (prefix.isNotEmpty()) sb.append(' ') - // Immediately reveal the first word so there's - // no visible gap between audio start and text. - 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) - } - // After all segments finished playing, ensure the full - // text is visible even if a reveal job was racing. + 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 if (ttsStreamer != null && qwen != null) { + // Streaming path: tail fragment (if any) + drain playback. + ttsStreamer.flush() + qwen.endStreamingSession() revealJobs.forEach { try { it.join() } catch (_: Exception) {} } updateMessageText(bubble.id, responseText) - } finally { - // Defensive: cancel the typing dots in case no - // segment ever fired (e.g. the response was entirely - // emojis and got stripped empty). - firstSegmentSeen.set(true) - try { typingJob.cancel() } catch (_: Exception) {} - _pipelineState.value = if (_isListening.value) - PipelineState.Listening else PipelineState.Idle - // If we're going back to mic listening, the VAD loop - // will keep pushing Listening signals; otherwise drop - // to Idle so the orb settles back to its breathing - // baseline. - if (!_isListening.value) { - _visualizerSignal.value = VisualizerSignal.Idle + metrics.end("ok") + } else { + // Legacy batch path for non-Qwen TTS engines. + _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 { - _pipelineState.value = if (_isListening.value) - PipelineState.Listening else PipelineState.Idle + // Empty response — close any open streaming session cleanly + // and drop the placeholder bubble. + try { qwen?.endStreamingSession() } catch (_: Exception) {} + _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") + try { qwen?.endStreamingSession() } catch (_: Exception) {} _pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM") addMessage(ChatMessage( role = ChatMessage.Role.SYSTEM, text = "Erreur lors de la génération: ${e.message}" )) - _pipelineState.value = PipelineState.Idle + } finally { + firstSegmentSeen.set(true) + try { typingJob.cancel() } catch (_: Exception) {} + _pipelineState.value = if (_isListening.value) + PipelineState.Listening else PipelineState.Idle + if (!_isListening.value) { + _visualizerSignal.value = VisualizerSignal.Idle + } } } @@ -1414,10 +1945,103 @@ class KazeiaService : Service() { override fun onDestroy() { super.onDestroy() serviceScope.cancel() - if (_isListening.value) audioCapture.stop() + _isListening.value = false // pilote l'arrêt des boucles AudioRecord + configReloadReceiver?.let { try { unregisterReceiver(it) } catch (_: Exception) {} } + stopThinkerEngine() try { llm.release() } catch (_: Exception) {} try { stt.release() } catch (_: Exception) {} try { tts.release() } catch (_: Exception) {} - try { vad.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) + } + } + + /** + * 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é. + */ + private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) { + val old = runtimeConfig + runtimeConfig = new + 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() + } + + /** + * 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()) + } } } diff --git a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt new file mode 100644 index 0000000..8f6934f --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt @@ -0,0 +1,576 @@ +package com.kazeia.telemetry + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.UriMatcher +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Debug +import android.os.Process +import java.io.File + +/** + * Expose à l'admin app (com.kazeia.admin) les données runtime du process + * Kazeia patient via une URI standard. + * + * Tables (read-only sauf voices delete) : + * - content://com.kazeia.provider/state — 1 row : état process courant + * - content://com.kazeia.provider/turns — N rows : derniers tours pipeline + * - content://com.kazeia.provider/crashes — N rows : derniers crashes + * - content://com.kazeia.provider/voices — N rows : voix présentes sur disque + * - content://com.kazeia.provider/voices/ — delete : supprime la voix + * + * Pas de permission custom en MVP — données non sensibles (système + meta voix). + * À sécuriser en production via signature-based permission une fois le keystore + * partagé mis en place (cf project_kazeia_admin_app.md). + */ +class KazeiaTelemetryProvider : ContentProvider() { + + companion object { + const val AUTHORITY = "com.kazeia.provider" + const val PATH_STATE = "state" + const val PATH_TURNS = "turns" + const val PATH_CRASHES = "crashes" + const val PATH_VOICES = "voices" + const val PATH_CONFIG = "config" + const val PATH_MODELS = "models" + const val PATH_PROFILES = "profiles" + const val PATH_PROFILE_ACTIVE = "profiles/active" + const val PATH_CONVERSATIONS = "conversations" + const val PATH_DIST_CONFIG = "dist_config" + const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG" + const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES" + + private const val CODE_STATE = 1 + private const val CODE_TURNS = 2 + private const val CODE_CRASHES = 3 + private const val CODE_VOICES = 4 + private const val CODE_VOICE_ITEM = 5 + private const val CODE_CONFIG = 6 + private const val CODE_MODELS = 7 + private const val CODE_PROFILES = 8 + private const val CODE_PROFILE_ITEM = 9 + private const val CODE_PROFILE_ACTIVE = 10 + private const val CODE_CONVERSATIONS = 11 + private const val CODE_CONVERSATION_SESSION = 12 + private const val CODE_CONVERSATION_PROFILE = 13 + private const val CODE_DIST_CONFIG = 14 + + // Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp + // sur tablette dev, stockage externe app sinon). Non-const. + private val VOICE_WAV_DIR: String + get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/../voix/voix" + private val VOICE_EMB_DIR: String + get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/qwen3-tts-npu" + private const val PREFIX_SUFFIX = "_voice_prefix.bin" + private const val SUFFIX_SUFFIX = "_voice_suffix.bin" + } + + private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply { + addURI(AUTHORITY, PATH_STATE, CODE_STATE) + addURI(AUTHORITY, PATH_TURNS, CODE_TURNS) + addURI(AUTHORITY, PATH_CRASHES, CODE_CRASHES) + addURI(AUTHORITY, PATH_VOICES, CODE_VOICES) + addURI(AUTHORITY, "$PATH_VOICES/*", CODE_VOICE_ITEM) + addURI(AUTHORITY, PATH_CONFIG, CODE_CONFIG) + addURI(AUTHORITY, PATH_MODELS, CODE_MODELS) + // Profiles : enum + active + per-id (CRUD) + addURI(AUTHORITY, PATH_PROFILES, CODE_PROFILES) + addURI(AUTHORITY, PATH_PROFILE_ACTIVE, CODE_PROFILE_ACTIVE) + addURI(AUTHORITY, "$PATH_PROFILES/*", CODE_PROFILE_ITEM) + // Conversations : timeline + filter by session/profile + addURI(AUTHORITY, PATH_CONVERSATIONS, CODE_CONVERSATIONS) + addURI(AUTHORITY, "$PATH_CONVERSATIONS/session/*", CODE_CONVERSATION_SESSION) + addURI(AUTHORITY, "$PATH_CONVERSATIONS/profile/*", CODE_CONVERSATION_PROFILE) + // Config WebDAV de distribution (éditable depuis l'app admin) + addURI(AUTHORITY, PATH_DIST_CONFIG, CODE_DIST_CONFIG) + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = when (matcher.match(uri)) { + CODE_STATE -> stateCursor() + CODE_TURNS -> turnsCursor() + CODE_CRASHES -> crashesCursor() + CODE_VOICES -> voicesCursor() + CODE_CONFIG -> configCursor() + CODE_MODELS -> modelsCursor() + CODE_PROFILES -> profilesCursor() + CODE_PROFILE_ACTIVE -> activeProfileCursor() + CODE_CONVERSATIONS -> sessionsCursor(profileId = null) + CODE_CONVERSATION_PROFILE -> sessionsCursor(profileId = uri.lastPathSegment) + CODE_CONVERSATION_SESSION -> turnsForSessionCursor(uri.lastPathSegment ?: "") + CODE_DIST_CONFIG -> distConfigCursor() + else -> null + } + + private fun profilesCursor(): Cursor { + val ctx = context ?: return MatrixCursor(arrayOf()) + val store = com.kazeia.profiles.ProfileStore.get(ctx) + val cols = arrayOf( + "id", "display_name", "avatar_color", "voice_id", + "system_prompt_override", "has_pin", "created_at", + "last_used_at", "notes", "is_default", "turn_count" + ) + val cursor = MatrixCursor(cols) + val dao = com.kazeia.profiles.ConversationDao( + com.kazeia.profiles.ConversationDb.get(ctx) + ) + for (p in store.all()) { + val count = try { dao.countTurns(p.id) } catch (_: Exception) { 0 } + cursor.addRow(arrayOf( + p.id, p.displayName, p.avatarColor, p.voiceId, + p.systemPromptOverride, if (p.hasPin()) 1 else 0, + p.createdAt, p.lastUsedAt, p.notes, + if (p.isDefault) 1 else 0, count + )) + } + return cursor + } + + private fun activeProfileCursor(): Cursor { + val ctx = context ?: return MatrixCursor(arrayOf()) + val cursor = MatrixCursor(arrayOf("active_profile_id")) + val active = com.kazeia.profiles.ProfileStore.get(ctx).activeProfile() + cursor.addRow(arrayOf(active?.id)) + return cursor + } + + private fun sessionsCursor(profileId: String?): Cursor { + val ctx = context ?: return MatrixCursor(arrayOf()) + val cols = arrayOf( + "profile_id", "session_id", "started_at", "ended_at", "turn_count" + ) + val cursor = MatrixCursor(cols) + try { + val dao = com.kazeia.profiles.ConversationDao( + com.kazeia.profiles.ConversationDb.get(ctx) + ) + for (s in dao.listSessions(profileId)) { + cursor.addRow(arrayOf(s.profileId, s.sessionId, + s.startedAt, s.endedAt, s.turnCount)) + } + } catch (_: Exception) { /* DB locked or new : retourne vide */ } + return cursor + } + + private fun turnsForSessionCursor(sessionId: String): Cursor { + val ctx = context ?: return MatrixCursor(arrayOf()) + val cols = arrayOf( + "id", "profile_id", "session_id", "timestamp", "role", + "text", "ttft_ms", "total_ms", "voice_used", "model_used" + ) + val cursor = MatrixCursor(cols) + try { + val dao = com.kazeia.profiles.ConversationDao( + com.kazeia.profiles.ConversationDb.get(ctx) + ) + for (t in dao.listTurns(sessionId = sessionId)) { + cursor.addRow(arrayOf( + t.id, t.profileId, t.sessionId, t.timestamp, t.role.name, + t.text, t.ttftMs, t.totalMs, t.voiceUsed, t.modelUsed + )) + } + } catch (_: Exception) { } + return cursor + } + + private fun configCursor(): Cursor { + val ctx = context ?: return MatrixCursor(arrayOf()) + val cfg = com.kazeia.config.ConfigStore.get(ctx).current() + val cols = arrayOf( + "cascade_enabled", "tts_enabled", + "stt_engine", "llm_engine", "tts_engine", + "speaker_model_id", "speaker_system_prompt", "speaker_temperature", + "thinker_model_id", "thinker_system_prompt", "thinker_temperature" + ) + val cursor = MatrixCursor(cols) + cursor.addRow(arrayOf( + if (cfg.cascadeEnabled) 1 else 0, + if (cfg.ttsEnabled) 1 else 0, + cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine, + cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature, + cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature + )) + return cursor + } + + private fun modelsCursor(): Cursor { + val cols = arrayOf( + "id", "display_name", "pte_path", "tokenizer_path", + "role", "size_mb", "max_seq_len", "file_exists", "notes" + ) + val cursor = MatrixCursor(cols) + for (m in com.kazeia.config.ModelRegistry.ALL) { + val path = if (m.backend == com.kazeia.config.ModelRegistry.Backend.GGUF) m.ggufPath() else m.ptePath() + cursor.addRow(arrayOf( + m.id, m.displayName, path, m.tokenizerPath(), + m.role.name, m.sizeMb, m.maxSeqLen, + if (m.fileExists()) 1 else 0, m.notes + )) + } + return cursor + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int { + if (values == null) return 0 + return when (matcher.match(uri)) { + CODE_CONFIG -> updateConfig(uri, values) + CODE_PROFILES -> upsertProfile(values) + CODE_PROFILE_ITEM -> upsertProfile(values.apply { + if (!containsKey("id")) put("id", uri.lastPathSegment) + }) + CODE_PROFILE_ACTIVE -> setActiveProfile(values.getAsString("active_profile_id")) + CODE_DIST_CONFIG -> updateDistConfig(uri, values) + else -> 0 + } + } + + /** + * Config WebDAV : 1 ligne. Le mot de passe n'est jamais renvoyé en clair — + * seul `has_password` indique s'il est défini. + */ + private fun distConfigCursor(): Cursor { + val ctx = context ?: return MatrixCursor(arrayOf()) + val cfg = com.kazeia.dist.DistConfigStore.get(ctx) + val cursor = MatrixCursor(arrayOf( + "webdav_base", "webdav_user", "has_password", "is_customized")) + cursor.addRow(arrayOf( + cfg.base, + cfg.user, + if (cfg.pass.isNotBlank()) 1 else 0, + if (com.kazeia.dist.DistConfigStore.isCustomized(ctx)) 1 else 0 + )) + return cursor + } + + private fun updateDistConfig(uri: Uri, values: ContentValues): Int { + val ctx = context ?: return 0 + // reset=true → retour au défaut d'usine (BuildConfig). + if (values.getAsBoolean("reset") == true) { + com.kazeia.dist.DistConfigStore.reset(ctx) + ctx.contentResolver.notifyChange(uri, null) + return 1 + } + val cur = com.kazeia.dist.DistConfigStore.get(ctx) + val base = values.getAsString("webdav_base") ?: cur.base + val user = values.getAsString("webdav_user") ?: cur.user + // Mot de passe : absent ou vide → inchangé ; non-vide → nouveau. + val pass = values.getAsString("webdav_pass")?.takeIf { it.isNotEmpty() } + com.kazeia.dist.DistConfigStore.update(ctx, base, user, pass) + ctx.contentResolver.notifyChange(uri, null) + return 1 + } + + private fun updateConfig(uri: Uri, values: ContentValues): Int { + val ctx = context ?: return 0 + val store = com.kazeia.config.ConfigStore.get(ctx) + val cur = store.current() + val newCfg = com.kazeia.config.ConfigStore.RuntimeConfig( + cascadeEnabled = if (values.containsKey("cascade_enabled")) + values.getAsBoolean("cascade_enabled") else cur.cascadeEnabled, + ttsEnabled = if (values.containsKey("tts_enabled")) + values.getAsBoolean("tts_enabled") else cur.ttsEnabled, + sttEngine = values.getAsString("stt_engine") ?: cur.sttEngine, + llmEngine = values.getAsString("llm_engine") ?: cur.llmEngine, + ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine, + speaker = com.kazeia.config.ConfigStore.ModelConfig( + modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId, + systemPrompt = values.getAsString("speaker_system_prompt") + ?: cur.speaker.systemPrompt, + temperature = values.getAsFloat("speaker_temperature") + ?: cur.speaker.temperature + ), + thinker = com.kazeia.config.ConfigStore.ModelConfig( + modelId = values.getAsString("thinker_model_id") ?: cur.thinker.modelId, + systemPrompt = values.getAsString("thinker_system_prompt") + ?: cur.thinker.systemPrompt, + temperature = values.getAsFloat("thinker_temperature") + ?: cur.thinker.temperature + ) + ) + store.save(newCfg) + ctx.contentResolver.notifyChange(uri, null) + ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_CONFIG)) + return 1 + } + + private fun upsertProfile(values: ContentValues): Int { + val ctx = context ?: return 0 + val store = com.kazeia.profiles.ProfileStore.get(ctx) + val id = values.getAsString("id") ?: com.kazeia.profiles.ProfileStore.newProfileId() + val existing = store.byId(id) + val displayName = values.getAsString("display_name") ?: existing?.displayName ?: id + val pinRaw = values.getAsString("pin") // PIN en clair pour set/clear + val newPinHash = when { + pinRaw == null -> existing?.pinHash // pas de changement + pinRaw.isEmpty() -> null // efface PIN + else -> com.kazeia.profiles.Profile.hashPin(pinRaw) + } + val p = com.kazeia.profiles.Profile( + id = id, + displayName = displayName, + avatarColor = values.getAsInteger("avatar_color") + ?: existing?.avatarColor + ?: com.kazeia.profiles.Profile.defaultColorFor(id), + voiceId = values.getAsString("voice_id") ?: existing?.voiceId, + systemPromptOverride = values.getAsString("system_prompt_override") + ?: existing?.systemPromptOverride, + pinHash = newPinHash, + createdAt = existing?.createdAt ?: System.currentTimeMillis(), + lastUsedAt = existing?.lastUsedAt ?: System.currentTimeMillis(), + notes = values.getAsString("notes") ?: existing?.notes, + isDefault = values.getAsBoolean("is_default") ?: existing?.isDefault ?: false + ) + store.upsert(p) + ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES)) + return 1 + } + + private fun setActiveProfile(profileId: String?): Int { + val ctx = context ?: return 0 + com.kazeia.profiles.ProfileStore.get(ctx).setActiveProfile(profileId) + ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES)) + return 1 + } + + private fun voicesCursor(): Cursor { + val cols = arrayOf( + "id", "name", "state", + "wav_exists", "prefix_exists", "suffix_exists", + "wav_size_bytes", "wav_duration_seconds", "wav_path", "created_at" + ) + val cursor = MatrixCursor(cols) + + // Set des ids = union (wav basenames) ∪ (prefix bin basenames) + val wavDir = File(VOICE_WAV_DIR) + val embDir = File(VOICE_EMB_DIR) + val wavIds = (wavDir.listFiles { f -> f.name.endsWith(".wav") } ?: emptyArray()) + .associateBy { it.nameWithoutExtension.lowercase() } + val prefixIds = (embDir.listFiles { f -> f.name.endsWith(PREFIX_SUFFIX) } ?: emptyArray()) + .associateBy { it.name.removeSuffix(PREFIX_SUFFIX).lowercase() } + val ids = (wavIds.keys + prefixIds.keys).sorted() + + for (id in ids) { + val wav = wavIds[id] + val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX") + val suffix = File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX") + val state = when { + prefix.exists() && suffix.exists() -> "ready" + wav != null -> "recorded" + else -> "error" + } + val durSec = wav?.let { wavDurationSeconds(it) } ?: 0f + cursor.addRow(arrayOf( + id, + id.replaceFirstChar { it.uppercase() }, + state, + if (wav != null) 1 else 0, + if (prefix.exists()) 1 else 0, + if (suffix.exists()) 1 else 0, + wav?.length() ?: 0L, + durSec, + wav?.absolutePath ?: "", + wav?.lastModified() ?: prefix.lastModified() + )) + } + return cursor + } + + /** Décode l'en-tête WAV pour estimer la durée. Très tolérant. */ + private fun wavDurationSeconds(f: File): Float = try { + java.io.RandomAccessFile(f, "r").use { raf -> + // Saute le RIFF header (12 bytes), parcourt les chunks + raf.seek(12) + var sampleRate = 0 + var channels = 0 + var bitsPerSample = 0 + var dataSize = 0L + while (raf.filePointer < raf.length() - 8) { + val chunkId = ByteArray(4).also { raf.readFully(it) } + val sizeBytes = ByteArray(4).also { raf.readFully(it) } + val size = ((sizeBytes[0].toInt() and 0xff) + or ((sizeBytes[1].toInt() and 0xff) shl 8) + or ((sizeBytes[2].toInt() and 0xff) shl 16) + or ((sizeBytes[3].toInt() and 0xff) shl 24)).toLong() and 0xffffffffL + val id = String(chunkId) + if (id == "fmt ") { + val pos = raf.filePointer + raf.skipBytes(2) // audio format + channels = raf.readUnsignedByte() or (raf.readUnsignedByte() shl 8) + sampleRate = raf.readUnsignedByte() or + (raf.readUnsignedByte() shl 8) or + (raf.readUnsignedByte() shl 16) or + (raf.readUnsignedByte() shl 24) + raf.seek(pos + size) + } else if (id == "data") { + dataSize = size + break + } else { + raf.skipBytes(size.toInt()) + } + if (raf.filePointer % 2L != 0L) raf.skipBytes(1) + } + // bitsPerSample : lu rapidement par estimation sinon 16 par défaut + if (sampleRate <= 0 || channels <= 0) 0f + else { + // bytes per sample = 2 par défaut + val bytesPerSample = 2 + val totalSamples = dataSize / (channels * bytesPerSample) + totalSamples.toFloat() / sampleRate + } + } + } catch (_: Exception) { 0f } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + val matched = matcher.match(uri) + android.util.Log.i("KazeiaProvider", "delete uri=$uri matched=$matched lastSeg=${uri.lastPathSegment}") + when (matched) { + CODE_VOICE_ITEM -> return deleteVoiceFiles(uri.lastPathSegment?.lowercase() ?: return 0) + CODE_PROFILE_ITEM -> return deleteProfile(uri.lastPathSegment ?: return 0) + CODE_CONVERSATION_PROFILE -> return deleteConversationsForProfile( + uri.lastPathSegment ?: return 0 + ) + CODE_CONVERSATION_SESSION -> return deleteConversationSession( + uri.lastPathSegment ?: return 0 + ) + } + return 0 + } + + private fun deleteVoiceFiles(id: String): Int { + var removed = 0 + listOf( + File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX"), + File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX"), + File(VOICE_WAV_DIR, "${id}.wav") + ).forEach { f -> + val exists = f.exists() + val ok = if (exists) f.delete() else false + android.util.Log.i("KazeiaProvider", " ${f.absolutePath} exists=$exists deleted=$ok") + if (exists && ok) removed++ + } + context?.contentResolver?.notifyChange( + Uri.parse("content://$AUTHORITY/$PATH_VOICES"), null + ) + context?.sendBroadcast(android.content.Intent("com.kazeia.action.RELOAD_VOICES")) + return removed + } + + private fun deleteProfile(id: String): Int { + val ctx = context ?: return 0 + // Supprime aussi tout l'historique de conversation associé + try { + com.kazeia.profiles.ConversationDao( + com.kazeia.profiles.ConversationDb.get(ctx) + ).deleteProfile(id) + } catch (_: Exception) { } + val ok = com.kazeia.profiles.ProfileStore.get(ctx).delete(id) + if (ok) ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES)) + return if (ok) 1 else 0 + } + + private fun deleteConversationsForProfile(id: String): Int { + val ctx = context ?: return 0 + return try { + com.kazeia.profiles.ConversationDao( + com.kazeia.profiles.ConversationDb.get(ctx) + ).deleteProfile(id) + } catch (_: Exception) { 0 } + } + + private fun deleteConversationSession(id: String): Int { + val ctx = context ?: return 0 + return try { + com.kazeia.profiles.ConversationDao( + com.kazeia.profiles.ConversationDb.get(ctx) + ).deleteSession(id) + } catch (_: Exception) { 0 } + } + + private fun stateCursor(): Cursor { + val cols = arrayOf( + "pid", "uptime_seconds", "pss_mb", "ion_mb", + "anon_pages_mb", "mem_available_mb" + ) + val cursor = MatrixCursor(cols) + + val info = Debug.MemoryInfo() + Debug.getMemoryInfo(info) + val pssMb = info.totalPss / 1024 + + val ionMb = readMeminfoKb("IonTotalUsed") / 1024 + val anonMb = readMeminfoKb("AnonPages") / 1024 + val availMb = readMeminfoKb("MemAvailable") / 1024 + + val uptimeSec = (System.currentTimeMillis() - TelemetryHolder.processStartedAt) / 1000 + + cursor.addRow(arrayOf( + Process.myPid(), + uptimeSec, + pssMb, + ionMb, + anonMb, + availMb + )) + return cursor + } + + private fun turnsCursor(): Cursor { + val cols = arrayOf( + "timestamp", "input_kind", "status", "total_ms", + "stt_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens", + "time_to_first_audio_ms", "audio_playback_ms" + ) + val cursor = MatrixCursor(cols) + for (t in TelemetryHolder.recentTurns()) { + cursor.addRow(arrayOf( + t.timestamp, t.inputKind, t.status, t.totalMs, + t.sttDurationMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens, + t.timeToFirstAudioMs, t.audioPlaybackMs + )) + } + return cursor + } + + private fun crashesCursor(): Cursor { + val cols = arrayOf("timestamp", "component", "message") + val cursor = MatrixCursor(cols) + for (c in TelemetryHolder.recentCrashes()) { + cursor.addRow(arrayOf(c.timestamp, c.component, c.message)) + } + return cursor + } + + private fun readMeminfoKb(key: String): Long = try { + File("/proc/meminfo").bufferedReader().useLines { lines -> + lines.firstOrNull { it.startsWith("$key:") } + ?.split(Regex("\\s+"))?.get(1)?.toLong() ?: 0L + } + } catch (_: Exception) { 0L } + + override fun getType(uri: Uri): String? = when (matcher.match(uri)) { + CODE_STATE -> "vnd.android.cursor.item/vnd.com.kazeia.state" + CODE_TURNS -> "vnd.android.cursor.dir/vnd.com.kazeia.turns" + CODE_CRASHES -> "vnd.android.cursor.dir/vnd.com.kazeia.crashes" + CODE_VOICES -> "vnd.android.cursor.dir/vnd.com.kazeia.voices" + CODE_VOICE_ITEM -> "vnd.android.cursor.item/vnd.com.kazeia.voice" + CODE_CONFIG -> "vnd.android.cursor.item/vnd.com.kazeia.config" + CODE_MODELS -> "vnd.android.cursor.dir/vnd.com.kazeia.models" + else -> null + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null +}