From 078804e2dece221196ca28aa126f45678a3885df Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Sun, 21 Jun 2026 14:42:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(debug):=20encart=20=C2=AB=20dernier=20tour?= =?UTF-8?q?=20=C2=BB=20+=20verrou=20half-duplex=20entr=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verrou half-duplex : pendant la génération LLM + la création TTS (avant le 1er son), l'entrée est verrouillée (toggleListening/interruptTts ignorés) — un tap micro ne casse plus la synthèse en cours. Déverrouillage au 1er son audible (l'usager peut alors interrompre). Flag audiblePlayback. - KazeiaService déclenche le pré-chauffage TTS (cv.warmup()) en tâche de fond après le chargement. - Métriques par étape : PipelineMetrics expose STT·Thinker·Speaker·TTS isolés ; snapshot /turns enrichi (thinker_ms, tts_ms) ; sinks vers le panneau debug. - Encart debug PERSISTANT « dernier tour » (remplace le graph GPU inutilisé) : phrase input · réponse output · temps par étape, en partition ADDITIVE Traitement (avant 1er son) / Lecture (durée parlée) dont la somme == TOTAL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/kazeia/core/PipelineMetrics.kt | 84 +++++++++++++++---- .../java/com/kazeia/service/KazeiaService.kt | 61 +++++++++++++- .../telemetry/KazeiaTelemetryProvider.kt | 8 +- .../com/kazeia/telemetry/TelemetryHolder.kt | 6 +- .../main/java/com/kazeia/ui/ChatActivity.kt | 10 ++- .../app/src/main/res/layout/activity_chat.xml | 21 ++++- 6 files changed, 157 insertions(+), 33 deletions(-) diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/PipelineMetrics.kt b/kazeia-android/app/src/main/java/com/kazeia/core/PipelineMetrics.kt index e231c92..6eb6bbf 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/core/PipelineMetrics.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/core/PipelineMetrics.kt @@ -34,6 +34,15 @@ class PipelineMetrics(val tag: String = "Pipeline") { private var inputKind: String = "" private var inputPayload: String = "" + /** Récepteur optionnel du récap par tour (1 ligne lisible). Branché par + * KazeiaService sur son `log()` → visible dans le panneau debug patient, + * pas seulement dans logcat. Découple PipelineMetrics du service. */ + @Volatile var summarySink: ((String) -> Unit)? = null + + /** Récepteur du bloc temps COMPLET (multi-lignes, 1 string) pour un encart + * debug PERSISTANT (vs les logs qui défilent). Mis à jour à chaque fin de tour. */ + @Volatile var timingsSink: ((String) -> Unit)? = null + fun begin(kind: String, payload: String) { t0 = System.currentTimeMillis() inputKind = kind @@ -94,29 +103,69 @@ class PipelineMetrics(val tag: String = "Pipeline") { .append('\n') } - // Derived KPIs that matter for perceived latency. + // Derived KPIs that matter for perceived latency. Per-stage durations + // (and not just cumulative timestamps) so STT / Thinker / Speaker / TTS + // are each isolable. + val sttStart = firstT("STT.start") ?: 0L val sttEnd = firstT("STT.end") + val thinkerStart = firstT("THINKER.start") + val thinkerEnd = firstT("THINKER.end") val llmStart = firstT("LLM.start") val llmFirstTok = firstT("LLM.first_token") val llmEnd = firstT("LLM.end") val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start") val lastAudio = lastT("TTS.seg.play_end") - sb.appendLine("[PIPELINE] ---- KPIs (ms since begin) ----") - if (sttEnd != null) sb.appendLine("[PIPELINE] STT duration : $sttEnd") - if (llmStart != null && llmFirstTok != null) - sb.appendLine("[PIPELINE] LLM time-to-first-token: ${llmFirstTok - llmStart}") - if (llmStart != null && llmEnd != null) - sb.appendLine("[PIPELINE] LLM generation : ${llmEnd - llmStart}") - if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← user waits this long") - if (firstAudio != null && lastAudio != null) - sb.appendLine("[PIPELINE] Audio playback window : ${lastAudio - firstAudio}") - sb.appendLine("[PIPELINE] Total wall time : $total") + val sttDur = sttEnd?.let { it - sttStart } + val thinkerDur = if (thinkerStart != null && thinkerEnd != null) thinkerEnd - thinkerStart else null + val speakerTtft = if (llmStart != null && llmFirstTok != null) llmFirstTok - llmStart else null + val speakerGen = if (llmStart != null && llmEnd != null) llmEnd - llmStart else null + // TTS « synthèse 1ʳᵉ phrase » = Speaker fini → 1er son audible. + val ttsSynth = if (llmEnd != null && firstAudio != null) firstAudio - llmEnd else null + val playback = if (firstAudio != null && lastAudio != null) lastAudio - firstAudio else null + + sb.appendLine("[PIPELINE] ---- KPIs (durées par étape, ms) ----") + if (sttDur != null) sb.appendLine("[PIPELINE] STT : $sttDur") + if (thinkerDur != null) sb.appendLine("[PIPELINE] Thinker (cascade) : $thinkerDur") + if (speakerTtft != null) sb.appendLine("[PIPELINE] Speaker time-to-1st-tok: $speakerTtft") + if (speakerGen != null) sb.appendLine("[PIPELINE] Speaker génération : $speakerGen") + if (ttsSynth != null) sb.appendLine("[PIPELINE] TTS → 1er son : $ttsSynth") + if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← attente perçue") + if (playback != null) sb.appendLine("[PIPELINE] Lecture audio : $playback") + sb.appendLine("[PIPELINE] Total : $total") // Single log call so it shows up contiguously even if the logger // interleaves other threads. Log.i(tag, sb.toString().trimEnd()) + // Bloc temps par étape (ne liste que les étapes réellement mesurées). + // Construit une fois, envoyé aux DEUX puits : summarySink (flot de logs) et + // timingsSink (encart persistant qui ne défile pas). + // DEUX BLOCS additifs : TRAITEMENT (latence avant le 1er son, ce qui compte + // pour la perf) + LECTURE (durée de la voix, dépend de la longueur du texte). + // Traitement + Lecture == TOTAL (exact). Le TTFT est un REPÈRE inclus dans + // Speaker, pas un poste à additionner. + run { + val b = StringBuilder("⏱─── TEMPS DU TOUR ($status) ───\n") + fun row(name: String, value: String) { b.append(" ${name.padEnd(11)} $value\n") } + // first_audio = frontière traitement / lecture ; à défaut (pas de TTS, + // ex. mode texte) tout est « traitement ». + val processingMs = firstAudio ?: total + val readingMs = (total - processingMs).coerceAtLeast(0) + b.append(" TRAITEMENT (avant le 1er son) : $processingMs ms\n") + if (sttDur != null) row("· STT", "$sttDur ms") + if (thinkerDur != null) row("· Thinker", "$thinkerDur ms") + if (speakerGen != null) row("· Speaker", "$speakerGen ms" + + (speakerTtft?.let { " (dont 1er token $it)" } ?: "")) + if (ttsSynth != null) row("· TTS→son", "$ttsSynth ms") + if (firstAudio != null) b.append(" LECTURE (durée parlée) : $readingMs ms\n") + b.append(" ─────────────────────────────\n") + b.append(" TOTAL : $total ms") + val block = b.toString() + runCatching { timingsSink?.invoke(block) } + summarySink?.let { sink -> block.lineSequence().forEach { runCatching { sink(it) } } } + } + // Push a structured snapshot to TelemetryHolder so the admin app // can pull it via ContentProvider. Best-effort — if anything goes // wrong here it must not affect the pipeline. @@ -129,15 +178,14 @@ class PipelineMetrics(val tag: String = "Pipeline") { inputKind = inputKind, status = status, totalMs = total, - sttDurationMs = sttEnd, - llmTtftMs = if (llmStart != null && llmFirstTok != null) - llmFirstTok - llmStart else null, - llmGenerationMs = if (llmStart != null && llmEnd != null) - llmEnd - llmStart else null, + sttDurationMs = sttDur, + thinkerMs = thinkerDur, + llmTtftMs = speakerTtft, + llmGenerationMs = speakerGen, llmTokens = tokensMatch, + ttsSynthMs = ttsSynth, timeToFirstAudioMs = firstAudio, - audioPlaybackMs = if (firstAudio != null && lastAudio != null) - lastAudio - firstAudio else null + audioPlaybackMs = playback ) ) } catch (e: Throwable) { 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 454a667..75e6b3b 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 @@ -132,7 +132,19 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think // 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 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 @@ -147,6 +159,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs + // Encart « dernier tour » persistant (input · output · temps par étape). + private val _lastTurnPanel = MutableStateFlow("⏱ dernier tour : —") + val lastTurnPanel: StateFlow = _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, @@ -470,6 +488,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think 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 @@ -699,8 +723,24 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think 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() { - if (_pipelineState.value is PipelineState.Speaking) { + // 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 @@ -710,7 +750,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think @Volatile private var pttRecording = false fun toggleListening() { - // If TTS is speaking, interrupt it + // 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() @@ -1346,6 +1394,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think 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 @@ -1500,6 +1550,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think 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") @@ -1616,6 +1669,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think _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'") @@ -1661,6 +1715,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think )) } 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 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 index 49dff1c..4953ea8 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/telemetry/KazeiaTelemetryProvider.kt @@ -731,15 +731,15 @@ class KazeiaTelemetryProvider : ContentProvider() { 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" + "stt_ms", "thinker_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens", + "tts_ms", "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 + t.sttDurationMs, t.thinkerMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens, + t.ttsSynthMs, t.timeToFirstAudioMs, t.audioPlaybackMs )) } return cursor diff --git a/kazeia-android/app/src/main/java/com/kazeia/telemetry/TelemetryHolder.kt b/kazeia-android/app/src/main/java/com/kazeia/telemetry/TelemetryHolder.kt index 5ee9266..074a6fa 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/telemetry/TelemetryHolder.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/telemetry/TelemetryHolder.kt @@ -22,9 +22,11 @@ object TelemetryHolder { val status: String, // "ok" | "llm_only" | "empty_response" | "error" val totalMs: Long, val sttDurationMs: Long?, - val llmTtftMs: Long?, - val llmGenerationMs: Long?, + val thinkerMs: Long? = null, // cascade Thinker (analyse silencieuse), si actif + val llmTtftMs: Long?, // Speaker : temps jusqu'au 1er token + val llmGenerationMs: Long?, // Speaker : génération complète val llmTokens: Int?, + val ttsSynthMs: Long? = null, // TTS : Speaker fini → 1er son (synthèse 1ʳᵉ phrase) val timeToFirstAudioMs: Long?, val audioPlaybackMs: Long? ) diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt index 2432e41..df66585 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/ChatActivity.kt @@ -181,7 +181,6 @@ class ChatActivity : AppCompatActivity() { private fun setupResourceMonitoring() { val graphCpu = findViewById(R.id.graphCpu) - val graphGpu = findViewById(R.id.graphGpu) val graphNpu = findViewById(R.id.graphNpu) val graphRam = findViewById(R.id.graphRam) @@ -189,7 +188,6 @@ class ChatActivity : AppCompatActivity() { val ramTotal = resourceMonitor!!.snapshot().ramTotalMb.toFloat() graphCpu?.configure("CPU", "%", 100f, android.graphics.Color.parseColor("#4CAF50")) - graphGpu?.configure("GPU", "%", 100f, android.graphics.Color.parseColor("#2196F3")) graphNpu?.configure("AI", "%", 100f, android.graphics.Color.parseColor("#FF9800")) // Affiche la conso PSS de Kazeia uniquement (~2 GB idle, ~5.5 GB pic). // Pas la RAM système globale (qui inclut ~3 GB d'OS Android dont on @@ -201,7 +199,6 @@ class ChatActivity : AppCompatActivity() { while (true) { val snap = resourceMonitor!!.snapshot() graphCpu?.addValue(snap.cpuPercent) - graphGpu?.addValue(if (snap.gpuPercent >= 0) snap.gpuPercent else 0f) // AI workload: show which AI component is active val workload = kazeiaService?.aiWorkload?.value @@ -318,6 +315,13 @@ class ChatActivity : AppCompatActivity() { } } } + launch { + // Encart « dernier tour » PERSISTANT (remplace le graph GPU) : + // input · output · temps par étape. + service.lastTurnPanel.collect { panel -> + findViewById(R.id.tvLastTurn)?.text = panel + } + } launch { // debugMode (piloté par l'admin via ConfigStore.debugEnabled) gate le // BOUTON debug. Si off : bouton masqué + panneau forcé fermé (écran diff --git a/kazeia-android/app/src/main/res/layout/activity_chat.xml b/kazeia-android/app/src/main/res/layout/activity_chat.xml index 21d7491..7ff89e2 100644 --- a/kazeia-android/app/src/main/res/layout/activity_chat.xml +++ b/kazeia-android/app/src/main/res/layout/activity_chat.xml @@ -191,12 +191,27 @@ android:layout_weight="1" android:layout_margin="2dp" /> - + + android:layout_margin="2dp" + android:background="#16202A"> + + + +