From e24caaa684c04dc3daa57332bf1529ae394a4031 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 23 Aug 2026 21:17:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=87=D0=BA=D0=B8:=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?SttCancel=20(user/timeout)=20=E2=80=94=20=D0=B2=D1=8B=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B8=D0=B7=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=82=D0=B8=D1=88=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=2030=D1=81,=20=D0=BC=D0=B8=D0=BA=D1=80=D0=BE=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=20=D1=84=D0=BE=D0=BA=D1=83=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK-stt-cancel-reason.md | 65 +++++++++++++++++++ .../binom/viewmate/glasses/HostConnection.kt | 22 +++++-- .../pw/binom/viewmate/glasses/MainActivity.kt | 26 ++++++-- .../pw/binom/viewmate/phone/GlassesServer.kt | 4 +- .../pw/binom/viewmate/phone/PhoneApp.kt | 2 +- .../viewmate/core/protocol/HostToGlasses.kt | 8 ++- .../core/protocol/HostToGlassesTest.kt | 19 ++++++ 7 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 TASK-stt-cancel-reason.md diff --git a/TASK-stt-cancel-reason.md b/TASK-stt-cancel-reason.md new file mode 100644 index 0000000..0407d2d --- /dev/null +++ b/TASK-stt-cancel-reason.md @@ -0,0 +1,65 @@ +# TASK-stt-cancel-reason.md — очки не должны слушать в фоне (цикл SttCancel) + +## Контекст (отчёт пользователя 23.08 ~21:10) +«Смотрю видео на фоне, очки рядом лежат — походу он всё это время слушает». По логам очков: +бесконечный цикл каждые ~31с: `стt отменено — возврат в «слушаю»` → микрофон запущен → +30с тишины → телефон шлёт SttCancel → очки снова открывают микрофон. При этом очки в лаунчере +(Activity не в фокусе), процесс жив. Очки даже распознают фоновый звук ТВ. Система начала +блокировать запись (AppOps RECORD_AUDIO Operation not started). + +**Корень:** `SttCancel` приходит от ДВУХ разных источников, а очки обрабатывают их одинаково: +1. **Пустой клик пользователя** (`GlassesServer`, StopStt(cancel=false) с пустым full) — по касяку №2 + очки должны вернуться в «слушаю» (resumeSttListening). +2. **Watchdog тишины 30с** (`PhoneApp.ensureStt` onSilence30s) — пользователь молчит/ушёл, нужно + ВЫЙТИ из режима общения (микрофон закрыть, оверлей скрыть), как было в старой логике SttCancel + (listening=false, thinking=false). +Сейчас оба шлют голый `SttCancel()`, и очки на любой из них вызывают resumeSttListening → цикл. + +## Правки + +### 1. lib-core — протокол: SttCancel получает reason +`lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt`: +```kotlin +@SerialName("stt_cancel") +class SttCancel(val reason: String = "user") : HostToGlasses +``` +- `reason="user"` — отмена/пустой клик пользователя: очки возвращаются в «слушаю»; +- `reason="timeout"` — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима (микрофон закрыть, + оверлей скрыть, как exitAssistantMode). +Backward-compatible: старое сообщение без поля десериализуется как reason="user". +Поправить тест round-trip SttCancel в `lib-core/src/commonTest/.../HostToGlassesTest.kt`. + +### 2. Телефон — источники +- `app-phone/.../PhoneApp.kt` onSilence30s (~строка 376): `broadcast(SttCancel())` → `broadcast(SttCancel(reason = "timeout"))`. +- `app-phone/.../GlassesServer.kt` StopStt(cancel=false) с пустым full: `broadcast(SttCancel())` → `broadcast(SttCancel(reason = "user"))` (явно; можно и дефолт, но явно читаемее). +- StopStt(cancel=true) (двойной клик, отмена распознавания): тоже `SttCancel(reason = "user")` — + пользователь остаётся в разговоре, очки возвращаются в «слушаю». + +### 3. Очки — обработка по reason +`app-glasses/.../HostConnection.kt` ветка `is SttCancel`: +- `reason == "timeout"` → выход из режима: `GlassesApp.instance.listening.value = false`, + `thinking.value = false`, и остановить микрофон. Для этого в `MainActivity` сделать + **публичный** метод (например `fun onSttTimeout()`), который вызывает логику exitAssistantMode() + (стоп микрофона + сброс состояний) — или сделать exitAssistantMode() публичной и вызывать + `GlassesApp.instance.mainActivity?.exitAssistantMode()`. Выбрать чистое решение по коду. +- иначе (user) → как сейчас: `GlassesApp.instance.thinking.value = false` + + `mainActivity?.resumeSttListening()`. + +### 4. Очки — защита «лежат и слушают» (onPause) +`app-glasses/.../MainActivity.kt` onPause(): сейчас там только пауза кино + sendPlaybackPositionNow. +Добавить: если режим общения активен (listening || thinking || assistantText.isNotBlank()) — +выход из режима общения (та же логика exitAssistantMode: sttMic.stop(), сброс состояний). +Т.е. при потере фокуса Activity (пользователь ушёл в лаунчер) микрофон гарантированно +закрывается, цикл невозможен. + +### 5. Сборка, тесты, коммит +- `./gradlew :lib-core:build :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL. +- `git add -A && git commit -m 'Очки: разграничение SttCancel (user/timeout) — выход из режима при тишине 30с, микрофон закрывается при потере фокуса'` (БЕЗ push). + +## КРИТИЧЕСКИЕ ПРАВИЛА +- workspace = /root/WORK/view-mate. Пути ТОЛЬКО относительные от корня (app-glasses/..., + app-phone/..., lib-core/...). НИКОГДА /root/WORK/app-* (без view-mate) — external_directory, + permission-guard отклонит. +- НЕ трогать: LocalLlm.kt (GPU), скорость, клавиатуру, автоименование, AssistantGestures.kt, + матрицу жестов, AssistantOverlay.kt (скролл уже починен). +- В конце — краткий отчёт: файлы, изменения, сборка, тесты. 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 47d3424..7a4a3d0 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,13 +360,21 @@ class HostConnection( } is SttCancel -> { - // Фразы не было (клик без голоса) или отмена двойным кликом: НЕ выходим - // из режима, а возвращаемся в «слушаю» — оверлей остаётся, микрофон снова - // открыт (TASK-glasses-chat-fix.md касяк №2). - log("stt", "отменено — возврат в «слушаю»") - GlassesApp.instance.thinking.value = false - GlassesApp.instance.mainActivity?.resumeSttListening() - ?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон") + 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 ещё нет — не могу вернуть микрофон") + } } is Shutdown -> { 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 4030b34..5198b97 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 @@ -321,8 +321,17 @@ class MainActivity : BaseEventActivity() { } /** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */ - private fun exitAssistantMode() { - log("gesture", "DoubleClick → выход из режима (кино остаётся на паузе)") + private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)") + + /** + * Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл — + * выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection. + */ + fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима") + + /** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */ + private fun finishAssistantMode(reason: String) { + log("gesture", reason) sttMic.stop() val app = GlassesApp.instance app.listening.value = false @@ -367,11 +376,18 @@ class MainActivity : BaseEventActivity() { override fun onPause() { super.onPause() log("ui", "onPause") - val was = GlassesApp.instance.movieController.pauseIfPlaying() + val app = GlassesApp.instance + // Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл + // в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым. + if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) { + log("ui", "onPause: режим общения активен — выход (микрофон закрыт)") + finishAssistantMode("onPause — потеря фокуса, выход из режима общения") + } + val was = app.movieController.pauseIfPlaying() resumeAfterScreenOn = resumeAfterScreenOn || was logEvent("onPause: pause wasPlaying=$was") - GlassesApp.instance.movieController.ensureWifiLock() - GlassesApp.instance.hostConnection.sendPlaybackPositionNow() + app.movieController.ensureWifiLock() + app.hostConnection.sendPlaybackPositionNow() if (was) log("ui", "экран погас — пауза") } 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 b0b41ed..d8793ac 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()) + send(connId, SttCancel(reason = "user")) stt?.reset() } else { val full = stt?.finish() ?: "" @@ -217,7 +217,7 @@ class GlassesHub( } else { // Фразы не было — очки возвращаются в «слушаю» (SttCancel). log("stt", "фразы не было — очки в «слушаю»") - broadcast(SttCancel()) + broadcast(SttCancel(reason = "user")) } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 19b99d2..32b912a 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -375,7 +375,7 @@ class PhoneApp : Application() { }, onSilence30s = { log("stt", "тишина 30 с — автo-отмена") - scope.launch { server.hub.broadcast(SttCancel()) } + scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) } }, ) sttStreamer = s 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 d01681c..7641bbc 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 @@ -91,10 +91,14 @@ data class SttPhrase(val phrase: String, val full: String) : HostToGlasses @SerialName("stt_done") data class SttDone(val full: String) : HostToGlasses -/** Распознавание отменено (двойной клик или 30 с тишины) — текст не выводится. */ +/** + * Распознавание отменено — текст не выводится. + * reason="user" — отмена/пустой клик пользователя: очки возвращаются в «слушаю»; + * reason="timeout" — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима. + */ @Serializable @SerialName("stt_cancel") -class SttCancel : HostToGlasses +class SttCancel(val reason: String = "user") : HostToGlasses /** Один элемент истории сессии (только user/assistant — tool/calls очкам не нужны). */ @Serializable diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt index 5f72125..bec9fe1 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -221,6 +221,25 @@ class HostToGlassesTest { assertEquals("""{"type":"stt_cancel"}""", text) val decoded = json.decodeFromString(HostToGlasses.serializer(), text) assertIs(decoded) + assertEquals("user", decoded.reason) + } + + @Test + fun sttCancelTimeoutRoundTrip() { + val msg: HostToGlasses = SttCancel(reason = "timeout") + val text = json.encodeToString(HostToGlasses.serializer(), msg) + assertEquals("""{"type":"stt_cancel","reason":"timeout"}""", text) + val decoded = json.decodeFromString(HostToGlasses.serializer(), text) + assertIs(decoded) + assertEquals("timeout", decoded.reason) + } + + @Test + fun sttCancelBackwardCompatibleDefault() { + // Старое сообщение без поля reason десериализуется как "user". + val decoded = json.decodeFromString(HostToGlasses.serializer(), """{"type":"stt_cancel"}""") + assertIs(decoded) + assertEquals("user", decoded.reason) } @Test