Очки: отмена слушания двойным тапом — SttCancel reason=cancel

This commit is contained in:
2026-08-24 03:01:55 +03:00
parent 504e66f0c6
commit 7557d7229f
6 changed files with 163 additions and 18 deletions
+90
View File
@@ -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-ом (не агента).
@@ -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 ещё нет — не могу вернуть микрофон")
}
}
}
@@ -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))
@@ -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))
}
}
}
@@ -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<SttCancel>(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<SttCancel>(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) {
@@ -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