From 245b8bab3b3573aca12320d4a2e59f72efe42fa2 Mon Sep 17 00:00:00 2001 From: NITISH-R-G Date: Sun, 26 Jul 2026 14:06:33 +0530 Subject: [PATCH] Make a voice turn stale-safe and actually observable Six bugs in the live voice path, all found by reading the turn lifecycle end to end. None of them show up on a slow, careful run; all of them show up when someone taps twice on stage. 1. Stale turns repainted the pill. Tapping the mic during the ~1.5s "Thinking..." window starts a new recording, then the older in-flight plan lands and repaints the pill to "Guiding you" while the user is still speaking. Every user action now opens a numbered turn, and background work drops itself if a newer turn has started. 2. The debug panel could never show what was heard. The transcript, intent and latency were written to debugSink, then the highlight resolution overwrote the whole panel with its own diagnostics milliseconds later on the same thread. Since long-pressing the pill is the only usable triage tool on device, half a panel meant no triage. A turn's diagnostics now accumulate into one VoiceDebug value. 3. recorder.stop() ran on the shared network thread, so a re-tap during an STT call could call start() before the pending stop() drained. start() saw the recorder still busy, returned false, and the turn silently degraded to the offline path. Recorder start/stop now own a dedicated capture thread and are serialized in tap order. 4. A denied microphone failed silently. recorder.start() returned false and the app quietly ran the deterministic task, so the user was never told why the voice loop does nothing. The permission is now checked up front and the reason is spoken, then guidance continues. 5. A double-tapped mic sent a header-only WAV to Saaras and waited a full round trip to be told nothing was said. Below 400ms of captured audio the call is skipped outright. 6. The reported STT latency was wrong. It was timed from before recorder.stop(), folding in that method's writer-thread join of up to 1.5s, which made STT look far over budget when it was measured at 729ms. It is now timed around the network call alone. Also: capture files are per-turn, so a new recording can no longer truncate the file a previous turn's upload is still streaming, and abandoned captures are purged rather than growing the cache all demo. No contract, schema or Milestone 1 component changed. The deterministic fallback still owns every failure path. Tested: assembleDebug and testDebugUnitTest green; 6 new VoiceDebug tests pin the accumulate-don't-replace behaviour that was broken. Co-Authored-By: Claude Opus 5 --- .../com/screensaathi/sarvam/WavRecorder.kt | 21 +- .../screensaathi/session/SessionController.kt | 313 +++++++++++++----- .../com/screensaathi/session/VoiceDebug.kt | 59 ++++ .../screensaathi/session/VoiceDebugTest.kt | 61 ++++ 4 files changed, 369 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/screensaathi/session/VoiceDebug.kt create mode 100644 app/src/test/java/com/screensaathi/session/VoiceDebugTest.kt diff --git a/app/src/main/java/com/screensaathi/sarvam/WavRecorder.kt b/app/src/main/java/com/screensaathi/sarvam/WavRecorder.kt index 046fa9e..8395efb 100644 --- a/app/src/main/java/com/screensaathi/sarvam/WavRecorder.kt +++ b/app/src/main/java/com/screensaathi/sarvam/WavRecorder.kt @@ -23,9 +23,22 @@ class WavRecorder { private val channel = AudioFormat.CHANNEL_IN_MONO private val encoding = AudioFormat.ENCODING_PCM_16BIT + /** PCM payload bytes captured in the last take, excluding the 44-byte header. */ + @Volatile + var bytesRecorded: Long = 0L + private set + + /** + * Captured audio length in ms. The caller uses this to refuse to spend an + * STT round trip on a double-tapped mic that recorded nothing. + */ + val recordedMs: Long + get() = bytesRecorded * 1000 / (sampleRate * BYTES_PER_SAMPLE) + @SuppressLint("MissingPermission") // caller ensures RECORD_AUDIO is granted fun start(outFile: File): Boolean { if (recording) return false + bytesRecorded = 0L val minBuf = AudioRecord.getMinBufferSize(sampleRate, channel, encoding) if (minBuf <= 0) return false val bufSize = minBuf * 2 @@ -67,6 +80,7 @@ class WavRecorder { if (n > 0) { raf.write(buf, 0, n) total += n + bytesRecorded = total } } // Patch sizes now that we know the payload length. @@ -79,7 +93,7 @@ class WavRecorder { } private fun writeWavHeader(raf: RandomAccessFile, dataLen: Int) { - val byteRate = sampleRate * 2 // mono * 16-bit + val byteRate = sampleRate * BYTES_PER_SAMPLE // mono * 16-bit val riffLen = 36 + dataLen val header = ByteArray(44) fun putStr(off: Int, s: String) { for (i in s.indices) header[off + i] = s[i].code.toByte() } @@ -100,5 +114,8 @@ class WavRecorder { raf.write(header) } - companion object { private const val TAG = "WavRecorder" } + companion object { + private const val TAG = "WavRecorder" + private const val BYTES_PER_SAMPLE = 2 + } } diff --git a/app/src/main/java/com/screensaathi/session/SessionController.kt b/app/src/main/java/com/screensaathi/session/SessionController.kt index 07d52b4..35c3c2e 100644 --- a/app/src/main/java/com/screensaathi/session/SessionController.kt +++ b/app/src/main/java/com/screensaathi/session/SessionController.kt @@ -1,11 +1,14 @@ package com.screensaathi.session +import android.Manifest import android.content.Context +import android.content.pm.PackageManager import android.graphics.Rect import android.os.Handler import android.os.HandlerThread import android.os.SystemClock import android.util.Log +import androidx.core.content.ContextCompat import com.screensaathi.ScreenReaderService import com.screensaathi.overlay.HighlightBounds import com.screensaathi.overlay.OverlayCommand @@ -20,6 +23,7 @@ import com.screensaathi.screen.ScreenSnapshot import com.screensaathi.task.GuidedTask import com.screensaathi.task.TaskRepository import java.io.File +import java.util.concurrent.atomic.AtomicInteger /** * The orchestration layer. Turns UI taps into OverlayCommands, driving the @@ -29,6 +33,15 @@ import java.io.File * Every network step has a deterministic fallback. If the key is missing or any * call fails, mic-tap starts the task and Next advances in order — the overlay * is never left frozen. + * + * ## Turns + * + * Every user action opens a numbered *turn*. Background work captures its turn + * number and drops itself if a newer turn has started since. Without this, a + * mic tap during the ~1.5 s "Thinking…" window starts a fresh recording, then + * the older in-flight plan lands and repaints the pill to "Guiding you" while + * the user is still speaking. The pill has to tell the truth about its state, + * so stale work is discarded rather than rendered. */ class SessionController( private val context: Context, @@ -41,6 +54,16 @@ class SessionController( private val worker = HandlerThread("saathi-session").apply { start() } private val bg = Handler(worker.looper) + /** + * Recorder start/stop run here and nowhere else, so they are serialized in + * tap order. Sharing [bg] with the network work meant a re-tap during the + * "Thinking…" window could call start() before the previous stop() had run + * off the back of a 1.5 s STT call — start() saw the recorder still busy, + * returned false, and the turn silently fell back to the offline path. + */ + private val captureWorker = HandlerThread("saathi-capture").apply { start() } + private val capture = Handler(captureWorker.looper) + private val tasks: TaskRepository = TaskRepository.load(context) private var engine: StepEngine? = null @@ -53,6 +76,23 @@ class SessionController( @Volatile private var isRecording = false @Volatile private var lastLanguage = "hi-IN" + /** Turn that owns the in-progress capture, so stop() reads the right file. */ + @Volatile private var recordingTurn = -1 + + /** + * Monotonic turn counter. Bumped by every user action; background work that + * finds itself outdated renders nothing. + */ + private val turnId = AtomicInteger(0) + + private fun newTurn(): Int = turnId.incrementAndGet() + private fun isCurrent(turn: Int): Boolean = turnId.get() == turn + + /** Render only if [turn] is still the live one. */ + private fun renderIfCurrent(turn: Int, cmd: OverlayCommand) { + if (isCurrent(turn)) render(cmd) + } + /** * The highlight currently on screen. Every subsequent render must carry it * forward: OverlayCommand.highlight defaults to null, so a state-only @@ -61,6 +101,17 @@ class SessionController( */ @Volatile private var currentHighlight: HighlightBounds? = null + /** Diagnostics for the live turn, shown by long-pressing the pill. */ + @Volatile private var debug = VoiceDebug() + + private fun publishDebug(turn: Int, update: (VoiceDebug) -> VoiceDebug) { + if (!isCurrent(turn)) return + val next = update(debug) + debug = next + Log.d(TAG, next.toPanel().replace("\n", " | ")) + debugSink?.update(next.toPanel()) + } + // --- UI events (main thread) ---------------------------------------------- fun onMicTapped() { @@ -68,160 +119,246 @@ class SessionController( } fun onNextTapped() { + // Opening a turn here invalidates any plan still in flight, so a slow + // planner response cannot yank the user back a step after they advance. + val turn = newTurn() + if (isRecording) { + // Advancing mid-utterance abandons it: that capture belongs to a + // turn the user has just walked away from. + isRecording = false + capture.post { recorder.stop(); purgeStaleCaptures(turn) } + } val e = engine if (e == null) { - // No task yet — start the default one deterministically. - startDefaultTask() + startDefaultTask(turn) return } if (e.isOnLastStep) { - render(OverlayCommand(PillState.IDLE, expanded = true, + currentHighlight = null + renderIfCurrent(turn, OverlayCommand(PillState.IDLE, expanded = true, instruction = "That's the last step — you're all done!", highlight = null)) return } e.advance() - presentCurrentStep(e, speak = true) + presentCurrentStep(e, turn, speak = true) } // --- Voice loop ----------------------------------------------------------- private fun startListening() { - if (!Sarvam.hasKey()) { - // No key: skip STT entirely, just run the task deterministically. - startDefaultTask() + val turn = newTurn() + + // A denied microphone used to fail silently: recorder.start() returned + // false and the app quietly ran the deterministic task, so the user was + // never told why the voice loop does nothing. Say it, then keep guiding. + if (!hasMicPermission()) { + startDefaultTask( + turn, + lead = "Microphone access is off, so I can't hear you — I'll guide you step by step.", + note = "RECORD_AUDIO denied", + ) return } - val f = File(context.cacheDir, "saathi_input.wav") - val ok = recorder.start(f) - if (!ok) { - startDefaultTask() + if (!Sarvam.hasKey()) { + // No key: skip STT entirely, just run the task deterministically. + startDefaultTask(turn, note = "no Sarvam key — deterministic path") return } + // Flip the pill first: the user must see "Listening…" the instant they + // tap, not after the capture thread has drained a pending stop(). isRecording = true + recordingTurn = turn currentHighlight = null - render(OverlayCommand(PillState.LISTENING, expanded = true, + debug = VoiceDebug() + renderIfCurrent(turn, OverlayCommand(PillState.LISTENING, expanded = true, instruction = "Listening… tap the mic again when you're done.")) + + capture.post { + purgeStaleCaptures(turn) + if (recorder.start(inputFileFor(turn))) return@post + isRecording = false + startDefaultTask(turn, note = "recorder failed to start") + } } private fun stopAndProcess() { isRecording = false + // The capture's own turn, not whatever is current — they diverge if + // anything opened a turn while the user was still speaking. + val turn = recordingTurn render(OverlayCommand(PillState.THINKING, expanded = true, instruction = "One moment…")) - bg.post { - val t0 = SystemClock.uptimeMillis() + + capture.post { recorder.stop() - val wav = File(context.cacheDir, "saathi_input.wav") - - val sttResult = stt.transcribe(wav) - val sttMs = SystemClock.uptimeMillis() - t0 - if (sttResult == null || sttResult.transcript.isBlank()) { - // Couldn't hear — fall back without dead-ending. - fallbackAfterFailedSpeech() - return@post - } - sttResult.languageCode?.let { lastLanguage = it } + val heldMs = recorder.recordedMs + bg.post { process(turn, heldMs) } + } + } - val task = pickTask(sttResult.transcript) - if (task == null) { - fallbackAfterFailedSpeech() - return@post - } - val e = engine?.takeIf { it.task.id == task.id } ?: StepEngine(task).also { engine = it } + /** Runs on [bg]. The whole network half of a voice turn. */ + private fun process(turn: Int, heldMs: Long) { + val wav = inputFileFor(turn) - val snap = ScreenReaderService.instance?.snapshot() ?: ScreenSnapshot.EMPTY - val tp0 = SystemClock.uptimeMillis() - val plan = planner.plan(sttResult.transcript, task, snap) - val planMs = SystemClock.uptimeMillis() - tp0 + // A double-tapped mic leaves a header-only WAV. Sending it costs a full + // round trip to be told nothing was said, which reads on stage as the + // app hanging. + if (heldMs < MIN_SPEECH_MS) { + publishDebug(turn) { it.copy(note = "too short (${heldMs}ms) — no STT call") } + wav.delete() + fallbackAfterFailedSpeech(turn, "I didn't catch that — hold the mic a moment longer.") + return + } - if (plan != null && plan.confidence >= CONFIDENCE_FLOOR && e.jumpTo(plan.step)) { - pushDebug(sttResult.transcript, plan.intent, plan.step, plan.targetResourceId, sttMs, planMs, plan.confidence) - presentStep(e, plan.instruction, speak = true) - } else { - // Planner unsure or unavailable — deterministic order wins. - pushDebug(sttResult.transcript, task.id, e.currentStep.id, e.currentStep.resourceId, sttMs, planMs, plan?.confidence ?: -1.0) - presentCurrentStep(e, speak = true) + // Timed around the network call alone. Measuring from before + // recorder.stop() folded in its writer-thread join (up to 1.5 s) and + // made STT look far over budget when it was not. + val t0 = SystemClock.uptimeMillis() + val sttResult = stt.transcribe(wav) + val sttMs = SystemClock.uptimeMillis() - t0 + wav.delete() + + if (!isCurrent(turn)) return + + if (sttResult == null || sttResult.transcript.isBlank()) { + publishDebug(turn) { it.copy(sttMs = sttMs, note = "STT returned nothing") } + fallbackAfterFailedSpeech(turn, "I didn't catch that — let's start here.") + return + } + sttResult.languageCode?.let { lastLanguage = it } + publishDebug(turn) { it.copy(heard = sttResult.transcript, sttMs = sttMs) } + + val task = pickTask(sttResult.transcript) + if (task == null) { + publishDebug(turn) { it.copy(note = "no task matched") } + fallbackAfterFailedSpeech(turn, "I didn't catch that — let's start here.") + return + } + val e = engine?.takeIf { it.task.id == task.id } ?: StepEngine(task).also { engine = it } + + val snap = ScreenReaderService.instance?.snapshot() ?: ScreenSnapshot.EMPTY + val tp0 = SystemClock.uptimeMillis() + val plan = planner.plan(sttResult.transcript, task, snap) + val planMs = SystemClock.uptimeMillis() - tp0 + + if (!isCurrent(turn)) return + + if (plan != null && plan.confidence >= CONFIDENCE_FLOOR && e.jumpTo(plan.step)) { + publishDebug(turn) { + it.copy( + intent = plan.intent, step = plan.step, + wantResourceId = plan.targetResourceId, + planMs = planMs, confidence = plan.confidence, + ) } + presentStep(e, plan.instruction, turn, speak = true) + } else { + // Planner unsure or unavailable — deterministic order wins. + publishDebug(turn) { + it.copy( + intent = task.id, step = e.currentStep.id, + wantResourceId = e.currentStep.resourceId, + planMs = planMs, confidence = plan?.confidence ?: -1.0, + note = if (plan == null) "planner unavailable" else "below confidence floor", + ) + } + presentCurrentStep(e, turn, speak = true) } } - private fun fallbackAfterFailedSpeech() { + private fun fallbackAfterFailedSpeech(turn: Int, lead: String) { val e = engine if (e != null) { - presentCurrentStep(e, speak = true) + presentStep(e, e.currentStep.instruction, turn, speak = true) } else { - val task = tasks.byId("pay_bill") ?: tasks.tasks.firstOrNull() + val task = tasks.byId(DEFAULT_TASK) ?: tasks.tasks.firstOrNull() if (task == null) { - render(OverlayCommand(PillState.ERROR, expanded = true, + renderIfCurrent(turn, OverlayCommand(PillState.ERROR, expanded = true, instruction = "No tasks are installed.")) } else { val ne = StepEngine(task); engine = ne - presentStep(ne, "I didn't catch that — let's start here.", speak = true) + presentStep(ne, lead, turn, speak = true) } } } // --- Deterministic path --------------------------------------------------- - private fun startDefaultTask() { - val task = tasks.byId("pay_bill") ?: tasks.tasks.firstOrNull() + /** + * Start (or restart) the default task with no network involved. [lead] lets + * the caller explain *why* we are on this path instead of silently + * pretending the voice loop ran. + */ + private fun startDefaultTask(turn: Int, lead: String? = null, note: String? = null) { + note?.let { n -> publishDebug(turn) { it.copy(note = n) } } + val task = tasks.byId(DEFAULT_TASK) ?: tasks.tasks.firstOrNull() if (task == null) { - render(OverlayCommand(PillState.ERROR, expanded = true, + renderIfCurrent(turn, OverlayCommand(PillState.ERROR, expanded = true, instruction = "No tasks are installed.")) return } val e = StepEngine(task); engine = e - presentCurrentStep(e, speak = true) + presentStep(e, lead ?: e.currentStep.instruction, turn, speak = true) } private fun pickTask(transcript: String): GuidedTask? = - tasks.matchByUtterance(transcript) ?: tasks.byId("pay_bill") ?: tasks.tasks.firstOrNull() + tasks.matchByUtterance(transcript) ?: tasks.byId(DEFAULT_TASK) ?: tasks.tasks.firstOrNull() - private fun presentCurrentStep(e: StepEngine, speak: Boolean) { - presentStep(e, e.currentStep.instruction, speak) + private fun presentCurrentStep(e: StepEngine, turn: Int, speak: Boolean) { + presentStep(e, e.currentStep.instruction, turn, speak) } /** * Show instruction immediately, resolve the highlight off-thread, speak the - * instruction. Rendering is always pushed back through the callback. + * instruction. Rendering is always pushed back through the callback, and + * always gated on [turn] still being live. */ - private fun presentStep(e: StepEngine, instruction: String, speak: Boolean) { + private fun presentStep(e: StepEngine, instruction: String, turn: Int, speak: Boolean) { val step = e.currentStep - render(OverlayCommand(PillState.GUIDING, expanded = true, instruction = instruction, highlight = null)) + renderIfCurrent(turn, OverlayCommand(PillState.GUIDING, expanded = true, + instruction = instruction, highlight = null)) bg.post { + if (!isCurrent(turn)) return@post val snap = ScreenReaderService.instance?.snapshot() val bounds = resolveBounds(step.resourceId) val hl = bounds?.let { HighlightBounds(it.left, it.top, it.right, it.bottom, step.highlight.shape, step.highlight.pulse) } - // Diagnostic: what did the reader actually see? - val diag = buildString { - append("want: ").append(step.resourceId).append("\n") - append("reader: ").append(if (ScreenReaderService.instance == null) "NULL" else "ok").append("\n") - append("pkg: ").append(snap?.packageName ?: "-").append(" settled=").append(snap?.settled).append("\n") - append("elems: ").append(snap?.elements?.size ?: 0).append("\n") - append("ids: ").append(snap?.elements?.filter { it.resourceId.isNotEmpty() }?.joinToString(",") { it.resourceId }?.take(80) ?: "-").append("\n") - append("bounds: ").append(bounds?.toShortString() ?: "NOT FOUND") + publishDebug(turn) { + it.copy( + wantResourceId = step.resourceId, + readerBound = ScreenReaderService.instance != null, + screenPackage = snap?.packageName ?: "-", + settled = snap?.settled, + elementCount = snap?.elements?.size ?: 0, + visibleIds = snap?.elements + ?.filter { el -> el.resourceId.isNotEmpty() } + ?.joinToString(",") { el -> el.resourceId } ?: "-", + bounds = bounds?.toShortString() ?: "NOT FOUND", + ) } - debugSink?.update(diag) + if (!isCurrent(turn)) return@post currentHighlight = hl - render(OverlayCommand(PillState.GUIDING, expanded = true, instruction = instruction, highlight = hl)) - if (speak) speak(instruction) + render(OverlayCommand(PillState.GUIDING, expanded = true, + instruction = instruction, highlight = hl)) + if (speak) speak(instruction, turn) } } - private fun speak(text: String) { + private fun speak(text: String, turn: Int) { if (!Sarvam.hasKey()) return val bytes = tts.synthesize(text, languageCode = lastLanguage) ?: return + if (!isCurrent(turn)) return // Carry currentHighlight through both renders — the ring must survive // the speaking state, not blink out while the instruction is read. player.play( bytes, onStart = { - render(OverlayCommand(PillState.SPEAKING, expanded = true, + renderIfCurrent(turn, OverlayCommand(PillState.SPEAKING, expanded = true, instruction = text, highlight = currentHighlight)) }, onDone = { - render(OverlayCommand(PillState.GUIDING, expanded = true, + renderIfCurrent(turn, OverlayCommand(PillState.GUIDING, expanded = true, instruction = text, highlight = currentHighlight)) }, ) @@ -237,25 +374,30 @@ class SessionController( return null } - private fun pushDebug( - transcript: String, intent: String, step: String, rid: String, - sttMs: Long, planMs: Long, confidence: Double, - ) { - val text = buildString { - append("heard: ").append(transcript.take(40)).append("\n") - append("intent: ").append(intent).append(" step: ").append(step).append("\n") - append("target: ").append(rid).append("\n") - append("stt: ").append(sttMs).append("ms plan: ").append(planMs).append("ms\n") - append("conf: ").append(if (confidence < 0) "fallback" else String.format("%.2f", confidence)) - } - Log.d(TAG, text.replace("\n", " | ")) - debugSink?.update(text) + private fun hasMicPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + + /** + * One capture file per turn. A shared filename let a new recording truncate + * the very file the previous turn's STT upload was still streaming. + */ + private fun inputFileFor(turn: Int) = File(context.cacheDir, "$CAPTURE_PREFIX$turn.wav") + + /** Abandoned turns leave their capture behind; don't grow the cache all demo. */ + private fun purgeStaleCaptures(keep: Int) { + val keepName = inputFileFor(keep).name + context.cacheDir.listFiles { f -> + f.name.startsWith(CAPTURE_PREFIX) && f.name != keepName + }?.forEach { it.delete() } } fun dispose() { + turnId.incrementAndGet() // invalidate anything still in flight player.stop() recorder.stop() worker.quitSafely() + captureWorker.quitSafely() } companion object { @@ -263,5 +405,10 @@ class SessionController( private const val RESOLVE_ATTEMPTS = 12 private const val RESOLVE_INTERVAL_MS = 120L private const val CONFIDENCE_FLOOR = 0.5 + private const val DEFAULT_TASK = "pay_bill" + private const val CAPTURE_PREFIX = "saathi_input_" + + /** Below this the clip is a mis-tap, not speech. */ + private const val MIN_SPEECH_MS = 400L } } diff --git a/app/src/main/java/com/screensaathi/session/VoiceDebug.kt b/app/src/main/java/com/screensaathi/session/VoiceDebug.kt new file mode 100644 index 0000000..39276e4 --- /dev/null +++ b/app/src/main/java/com/screensaathi/session/VoiceDebug.kt @@ -0,0 +1,59 @@ +package com.screensaathi.session + +/** + * One voice turn's diagnostics, accumulated and rendered as a single panel. + * + * This exists because the debug panel used to be written twice per turn from + * two different places — transcript/intent/latency first, then the highlight + * resolution diagnostics — milliseconds apart on the same background thread. + * The second write always destroyed the first, so the panel could never show + * what the app actually heard. Long-pressing the pill is the only usable triage + * tool on device (logcat is far too noisy), so half a panel meant no triage. + * + * Everything is nullable and rendered only when set, so the panel stays short + * on the deterministic path and grows into the full picture on a voice turn. + */ +data class VoiceDebug( + val heard: String? = null, + val intent: String? = null, + val step: String? = null, + val wantResourceId: String? = null, + val sttMs: Long? = null, + val planMs: Long? = null, + /** Negative means "planner not consulted / rejected", rendered as `fallback`. */ + val confidence: Double? = null, + val readerBound: Boolean? = null, + val screenPackage: String? = null, + val settled: Boolean? = null, + val elementCount: Int? = null, + val visibleIds: String? = null, + val bounds: String? = null, + /** Free-text reason the turn took the path it did. */ + val note: String? = null, +) { + + fun toPanel(): String = buildString { + heard?.let { line("heard", it.take(40)) } + if (intent != null || step != null) { + line("intent", "${intent ?: "-"} step: ${step ?: "-"}") + } + wantResourceId?.let { line("want", it) } + if (sttMs != null || planMs != null) { + line("latency", "stt ${sttMs ?: "-"}ms plan ${planMs ?: "-"}ms") + } + confidence?.let { + line("conf", if (it < 0) "fallback" else String.format("%.2f", it)) + } + readerBound?.let { line("reader", if (it) "bound" else "NULL — re-toggle in Settings") } + if (screenPackage != null || elementCount != null) { + line("screen", "${screenPackage ?: "-"} settled=${settled ?: "-"} elems=${elementCount ?: 0}") + } + visibleIds?.let { line("ids", it.take(80)) } + bounds?.let { line("bounds", it) } + note?.let { line("note", it) } + }.trimEnd() + + private fun StringBuilder.line(label: String, value: String) { + append(label).append(": ").append(value).append('\n') + } +} diff --git a/app/src/test/java/com/screensaathi/session/VoiceDebugTest.kt b/app/src/test/java/com/screensaathi/session/VoiceDebugTest.kt new file mode 100644 index 0000000..6e9246d --- /dev/null +++ b/app/src/test/java/com/screensaathi/session/VoiceDebugTest.kt @@ -0,0 +1,61 @@ +package com.screensaathi.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The debug panel is the only usable triage tool on device. These tests pin the + * behaviour that was actually broken: a turn's fields must *accumulate*, not + * replace each other, so the panel still shows what was heard after the + * highlight resolution writes its own diagnostics. + */ +class VoiceDebugTest { + + @Test + fun `accumulating a turn keeps every earlier field`() { + var d = VoiceDebug() + d = d.copy(heard = "bijli ka bill bharna hai", sttMs = 729) + d = d.copy(intent = "pay_bill", step = "amount", planMs = 606, confidence = 0.97) + // The highlight resolution used to overwrite the panel at this point. + d = d.copy(readerBound = true, elementCount = 7, bounds = "[40,220][680,300]") + + val panel = d.toPanel() + assertTrue("transcript must survive the bounds write", panel.contains("bijli ka bill")) + assertTrue(panel.contains("stt 729ms")) + assertTrue(panel.contains("plan 606ms")) + assertTrue(panel.contains("pay_bill")) + assertTrue(panel.contains("0.97")) + assertTrue(panel.contains("[40,220][680,300]")) + } + + @Test + fun `an empty panel is empty rather than a wall of dashes`() { + assertEquals("", VoiceDebug().toPanel()) + } + + @Test + fun `negative confidence renders as fallback`() { + assertTrue(VoiceDebug(confidence = -1.0).toPanel().contains("conf: fallback")) + assertTrue(VoiceDebug(confidence = 0.42).toPanel().contains("conf: 0.42")) + } + + @Test + fun `an unbound reader is called out, because that is the demo-day landmine`() { + val panel = VoiceDebug(readerBound = false).toPanel() + assertTrue(panel.contains("NULL")) + assertTrue("must say what to do about it", panel.contains("Settings")) + } + + @Test + fun `a long transcript is truncated so the panel stays readable`() { + val panel = VoiceDebug(heard = "x".repeat(200)).toPanel() + assertTrue(panel.length < 80) + } + + @Test + fun `a partial turn renders only what it knows`() { + val panel = VoiceDebug(note = "no Sarvam key — deterministic path").toPanel() + assertEquals("note: no Sarvam key — deterministic path", panel) + } +}