diff --git a/TASK-cancel-listen.md b/TASK-cancel-listen.md new file mode 100644 index 0000000..5e0fad3 --- /dev/null +++ b/TASK-cancel-listen.md @@ -0,0 +1,90 @@ +# TASK-cancel-listen: отмена слушания двойным тапом + +## Симптом (с железа, 24.08) +Пользователь в чате: сказал фразу → ассистент ответил. Далее **один тап** (чтобы снова начать +слушать) → очки начали слушать (микрофон открыт), пользователь молчит. Теперь хочет **отменить +слушание** — по замыслу это **двойной тап** — но отмена НЕ происходит: либо ничего не меняется, +либо микрофон снова открывается, кружок записи гаснет/возвращается непонятно. Состояние +«слушаю» залипает. + +## Причина (расследование — opencode) +Ключевой дефект: на стороне очков ветка `SttCancel` реагирует на `reason` некорректно. +- «Пустой клик» (тап без голоса) и «двойной клик-отмена» на телефоне приходят на очки ОДИНАКОВО + (`SttCancel` с `reason="user"`), и очки на любой делают `resumeSttListening()` → микрофон снова + открыт, из режима не выходим. +- После закрытия микрофона (`sendPhrase()`/`cancelDictation()`, `MainActivity.kt` ~279-321) + флаг `listening` **остаётся `true`**, и в `AssistantGestures.kt` (~34-39) `listening` побеждает + все остальные ветки → никакого двойного тапа в ANSWER/THINKING не видно, «выход из режима» + недоступен. + +## Целевое поведение (договорно) +1. **Один тап** (в режиме общения, не слушаю) → начать слушать (микрофон открыт, `listening=true`, + красный кружок). Без изменений. +2. **Пустой клик без голоса** (тап, но человек ничего не сказал) → телефон шлёт `StopStt(cancel=false)` + с пустым текстом → очки **возвращаются в «слушаю»** (как сейчас, `resumeSttListening`) — это + желаемое поведение, НЕ трогать. +3. **Двойной тап в состоянии «слушаю»** → **отмена диктовки**: микрофон закрыт, `listening=false`, + `thinking=false`, остаёмся в режиме общения (показываем последний ответ), НЕ выходим из режима + целиком. И НЕ открываем микрофон заново. Это новое поведение — сейчас оно не работает. +4. **Таймаут тишины 30с** (`reason="timeout"`) → выход ИЗ режима общения (`exitAssistantMode`), + как сейчас. НЕ трогать. +5. После любой посылки/отмены микрофон должен быть закрыт, `listening` сбрасывать на `false`. + +## Механика правки (семантика протокола) +`SttCancel.reason` — свободная строка, значения: +- `"user"` — пустой клик без голоса → очки возвращаются в «слушаю» (resume). (без изменений) +- `"timeout"` — тишина 30с → очки ВЫХОДЯТ из режима (exitAssistantMode). (без изменений) +- **`"cancel"`** — НОВОЕ: явная отмена диктовки двойным тапом → очки закрывают микрофон, + `listening=false`, `thinking=false`, остаются в режиме (показывают последний ответ), + НЕ открывают микрофон заново. + +### Точки правки (подтверди точные строки/логику при чтении) +1. **lib-core** `.../core/protocol/Messages.kt` — `SttCancel` уже несёт `reason: String = "user"`. + Ввести константу-компаньон `SttCancel.REASON_CANCEL = "cancel"` (и REASON_USER/REASON_TIMEOUT + для читабельности). Протокол меняется на ОБЕИХ сторонах — после правки переустанавливать И + app-glasses, И app-phone (рассинхрон версий вернёт старый баг). +2. **app-phone** `.../phone/GlassesServer.kt` (~206) — обработчик `StopStt`: + - `StopStt(cancel=true)` → бродкастить `SttCancel(reason=REASON_CANCEL)` (ранее шло "user"). + - `StopStt(cancel=false)` с пустым `full` → `SttCancel(reason=REASON_USER)` (возврат в «слушаю»). + - `StopStt(cancel=false)` с непустым текстом → `SttDone(...)` (без изменений). +3. **app-glasses** `.../glasses/.../HostConnection.kt` (~362-377) — ветка `SttCancel`: + - `reason == REASON_CANCEL` → закрыть микрофон + `listening=false` + `thinking=false`, + НЕ `resumeSttListening()`, НЕ выход из режима — остаться в режиме со следующим ответом. + - `reason == REASON_USER` → как сейчас (`thinking=false` + `resumeSttListening()`). + - `reason == REASON_TIMEOUT` → `exitAssistantMode()` (как сейчас). +4. **app-glasses** `.../glasses/MainActivity.kt` (~279-321) — `sendPhrase()` и `cancelDictation()`: + после закрытия микрофона обязательно `listening=false`. Ввести/использовать `cancelDictation()` + как точку «отмена слушания» (закрывает микрофон, сбрасывает `listening`). +5. **app-glasses** `.../glasses/gestures/AssistantGestures.kt` (~34-39, ~103-107) — проверить, что + **двойной тап в ветке LISTENING** маршрутизируется в `cancelDictation()` (которое шлёт + `StopStt(cancel=true)` → на телефоне `StopStt(cancel=true)` → `SttCancel(REASON_CANCEL)`). + Убедиться, что приоритет `listening` не перебивает двойной тап и не «залипает». + +## Подтвердить при чтении (не догадываться) +- Реально ли `GestureDetectorWithSound` при двойном тапе НЕ дёргает промежуточный single-Click + (гонка таймингов). Если дёргает — двойной тап превращается в single+single и отмена не + срабатывает. При необходимости поправить дискриминацию single/double. +- Точные имена полей/функций в `Messages.kt` и номера строк в `HostConnection.kt`/`GlassesServer.kt` + — сверить с реальным кодом, не брать на веру из описания выше. + +## Ограничения +- НЕ менять поведение `reason="user"` (пустой клик → возврат в «слушаю») и `reason="timeout"` + (выход из режима) — это зафиксированные решения от 23.08. +- Рабочая копия: `/root/WORK/view-mate`. Не трогать абсолютные пути вне workspace. +- Картинки/red-индикатор (504e66f) и историю диалога (706cc10) не ломать. +- Язык кода — только Kotlin. + +## Тесты +- **JVM-юнит** (lib-core + app-glasses gestures): новый `SttCancel(reason="cancel")`: + - очки НЕ вызывают `resumeSttListening()`, НЕ делают `exitAssistantMode()`, `listening=false`. + - `reason="user"` → `resumeSttListening()` (регресс). + - `reason="timeout"` → `exitAssistantMode()` (регресс). +- **app-phone test** (`GlassesServerTest`): `StopStt(cancel=true)` → бродкастится `SttCancel("cancel")`; + `StopStt(cancel=false)` пустой → `SttCancel("user")`; непустой → `SttDone`. +- Гейт: `./gradlew :lib-core:jvmTest :app-glasses:testDebugUnitTest :app-phone:testDebugUnitTest`. +- Полная сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`. + +## Примечание по приёмке на железе +DEBUG-крюк: `adb -s 7687f645 shell 'am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type double_click'` +проверяет, как машина жествов реагирует на двойной тап в состоянии «слушаю». Проверка на очках +после установки — за Hermes-ом (не агента). diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 7a4a3d0..3af7cc0 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -360,20 +360,30 @@ class HostConnection( } is SttCancel -> { - if (msg.reason == "timeout") { - // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из - // режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode. - log("stt", "тишина 30с — выход из режима общения") - GlassesApp.instance.mainActivity?.onSttTimeout() - ?: log("stt", "mainActivity ещё нет — не могу выйти из режима") - } else { - // Фразы не было (клик без голоса) или отмена двойным кликом: НЕ выходим - // из режима, а возвращаемся в «слушаю» — оверлей остаётся, микрофон снова - // открыт (TASK-glasses-chat-fix.md касяк №2). - log("stt", "отменено — возврат в «слушаю»") - GlassesApp.instance.thinking.value = false - GlassesApp.instance.mainActivity?.resumeSttListening() - ?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон") + when (msg.reason) { + SttCancel.REASON_CANCEL -> { + // Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить + // listening/thinking, НО остаться в режиме (показать последний ответ), + // микрофон заново НЕ открывать, из режима НЕ выходить. + log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме") + GlassesApp.instance.listening.value = false + GlassesApp.instance.thinking.value = false + } + SttCancel.REASON_TIMEOUT -> { + // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из + // режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode. + log("stt", "тишина 30с — выход из режима общения") + GlassesApp.instance.mainActivity?.onSttTimeout() + ?: log("stt", "mainActivity ещё нет — не могу выйти из режима") + } + else -> { + // reason="user" — пустой клик без голоса: не выходим из режима, + // возвращаемся в «слушаю» (микрофон снова открыт). + log("stt", "отменено — возврат в «слушаю»") + GlassesApp.instance.thinking.value = false + GlassesApp.instance.mainActivity?.resumeSttListening() + ?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон") + } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index 5198b97..139e39b 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -279,6 +279,7 @@ class MainActivity : BaseEventActivity() { private fun sendPhrase() { log("gesture", "Click → отправить фразу") sttMic.stop() + GlassesApp.instance.listening.value = false GlassesApp.instance.thinking.value = true lifecycleScope.launch { GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false)) @@ -314,6 +315,7 @@ class MainActivity : BaseEventActivity() { private fun cancelDictation() { log("gesture", "DoubleClick → отмена распознавания") sttMic.stop() + GlassesApp.instance.listening.value = false GlassesApp.instance.thinking.value = false lifecycleScope.launch { GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true)) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index d8793ac..ea6b6d3 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -203,7 +203,7 @@ class GlassesHub( is StopStt -> { if (msg.cancel) { log("stt", "распознавание отменено (очки)") - send(connId, SttCancel(reason = "user")) + send(connId, SttCancel(reason = SttCancel.REASON_CANCEL)) stt?.reset() } else { val full = stt?.finish() ?: "" @@ -217,7 +217,7 @@ class GlassesHub( } else { // Фразы не было — очки возвращаются в «слушаю» (SttCancel). log("stt", "фразы не было — очки в «слушаю»") - broadcast(SttCancel(reason = "user")) + broadcast(SttCancel(reason = SttCancel.REASON_USER)) } } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt index a1641da..9c2e8ac 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt @@ -22,6 +22,8 @@ import pw.binom.viewmate.core.protocol.GlassesToHost import pw.binom.viewmate.core.protocol.Hello import pw.binom.viewmate.core.protocol.HostToGlasses import pw.binom.viewmate.core.protocol.SetMode +import pw.binom.viewmate.core.protocol.StopStt +import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.protocolJson import kotlin.test.Test @@ -164,6 +166,39 @@ class GlassesServerTest { assertEquals("0.2.0", hub.appVersion.value) } + // ---------- StopStt → SttCancel/SttDone (TASK-cancel-listen.md) ---------- + + /** StopStt(cancel=true) — двойной тап-отмена на очках: очки получают SttCancel(reason=cancel). */ + @Test + fun stopSttCancelSendsSttCancelReasonCancel() = runServer { port, hub -> + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { + send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = true))) + val frame = incoming.receive() as Frame.Text + val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) + val cancel = assertIs(msg) + assertEquals(SttCancel.REASON_CANCEL, cancel.reason) + } + } + + /** StopStt(cancel=false) с пустым текстом — очки возвращаются в «слушаю»: SttCancel(reason=user). */ + @Test + fun stopSttEmptyTextBroadcastsSttCancelReasonUser() = runServer { port, hub -> + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { + send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false))) + val frame = incoming.receive() as Frame.Text + val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) + val cancel = assertIs(msg) + assertEquals(SttCancel.REASON_USER, cancel.reason) + } + } + + // Полная фраза (StopStt(cancel=false) с текстом → SttDone + onStopFullText) в JVM-юните + // не проверяется: реальный SttStreamer держит нативный VAD (com.k2fsa.sherpa.onnx.Vad), + // а нативная библиотека sherpa-onnx есть ТОЛЬКО под Android-bionic — в JVM-юните её нет, + // и фейк падает UnsatisfiedLinkError (неремонтируемо). Сценарий покрывается на устройстве. + // ---------- помощники ---------- private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) { diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt index 7641bbc..3ef44c1 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -93,12 +93,20 @@ data class SttDone(val full: String) : HostToGlasses /** * Распознавание отменено — текст не выводится. - * reason="user" — отмена/пустой клик пользователя: очки возвращаются в «слушаю»; + * reason="user" — пустой клик без голоса: очки возвращаются в «слушаю»; + * reason="cancel" — явная отмена диктовки двойным тапом: очки закрывают микрофон, + * listening=false, thinking=false, остаются в режиме (показывают последний ответ); * reason="timeout" — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима. */ @Serializable @SerialName("stt_cancel") -class SttCancel(val reason: String = "user") : HostToGlasses +class SttCancel(val reason: String = REASON_USER) : HostToGlasses { + companion object { + const val REASON_USER = "user" + const val REASON_CANCEL = "cancel" + const val REASON_TIMEOUT = "timeout" + } +} /** Один элемент истории сессии (только user/assistant — tool/calls очкам не нужны). */ @Serializable