Очки: отмена слушания двойным тапом — SttCancel reason=cancel
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user