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) + } +}