Очки: разграничение SttCancel (user/timeout) — выход из режима при тишине 30с, микрофон закрывается при потере фокуса
This commit is contained in:
@@ -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 (скролл уже починен).
|
||||||
|
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
|
||||||
@@ -360,13 +360,21 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SttCancel -> {
|
is SttCancel -> {
|
||||||
// Фразы не было (клик без голоса) или отмена двойным кликом: НЕ выходим
|
if (msg.reason == "timeout") {
|
||||||
// из режима, а возвращаемся в «слушаю» — оверлей остаётся, микрофон снова
|
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
|
||||||
// открыт (TASK-glasses-chat-fix.md касяк №2).
|
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
|
||||||
log("stt", "отменено — возврат в «слушаю»")
|
log("stt", "тишина 30с — выход из режима общения")
|
||||||
GlassesApp.instance.thinking.value = false
|
GlassesApp.instance.mainActivity?.onSttTimeout()
|
||||||
GlassesApp.instance.mainActivity?.resumeSttListening()
|
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
|
||||||
?: 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 -> {
|
is Shutdown -> {
|
||||||
|
|||||||
@@ -321,8 +321,17 @@ class MainActivity : BaseEventActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
||||||
private fun exitAssistantMode() {
|
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
|
||||||
log("gesture", "DoubleClick → выход из режима (кино остаётся на паузе)")
|
|
||||||
|
/**
|
||||||
|
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
|
||||||
|
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
|
||||||
|
*/
|
||||||
|
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||||
|
|
||||||
|
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
||||||
|
private fun finishAssistantMode(reason: String) {
|
||||||
|
log("gesture", reason)
|
||||||
sttMic.stop()
|
sttMic.stop()
|
||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
app.listening.value = false
|
app.listening.value = false
|
||||||
@@ -367,11 +376,18 @@ class MainActivity : BaseEventActivity() {
|
|||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
log("ui", "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
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||||
logEvent("onPause: pause wasPlaying=$was")
|
logEvent("onPause: pause wasPlaying=$was")
|
||||||
GlassesApp.instance.movieController.ensureWifiLock()
|
app.movieController.ensureWifiLock()
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
app.hostConnection.sendPlaybackPositionNow()
|
||||||
if (was) log("ui", "экран погас — пауза")
|
if (was) log("ui", "экран погас — пауза")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class GlassesHub(
|
|||||||
is StopStt -> {
|
is StopStt -> {
|
||||||
if (msg.cancel) {
|
if (msg.cancel) {
|
||||||
log("stt", "распознавание отменено (очки)")
|
log("stt", "распознавание отменено (очки)")
|
||||||
send(connId, SttCancel())
|
send(connId, SttCancel(reason = "user"))
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
} else {
|
} else {
|
||||||
val full = stt?.finish() ?: ""
|
val full = stt?.finish() ?: ""
|
||||||
@@ -217,7 +217,7 @@ class GlassesHub(
|
|||||||
} else {
|
} else {
|
||||||
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
|
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
|
||||||
log("stt", "фразы не было — очки в «слушаю»")
|
log("stt", "фразы не было — очки в «слушаю»")
|
||||||
broadcast(SttCancel())
|
broadcast(SttCancel(reason = "user"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ class PhoneApp : Application() {
|
|||||||
},
|
},
|
||||||
onSilence30s = {
|
onSilence30s = {
|
||||||
log("stt", "тишина 30 с — автo-отмена")
|
log("stt", "тишина 30 с — автo-отмена")
|
||||||
scope.launch { server.hub.broadcast(SttCancel()) }
|
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
sttStreamer = s
|
sttStreamer = s
|
||||||
|
|||||||
@@ -91,10 +91,14 @@ data class SttPhrase(val phrase: String, val full: String) : HostToGlasses
|
|||||||
@SerialName("stt_done")
|
@SerialName("stt_done")
|
||||||
data class SttDone(val full: String) : HostToGlasses
|
data class SttDone(val full: String) : HostToGlasses
|
||||||
|
|
||||||
/** Распознавание отменено (двойной клик или 30 с тишины) — текст не выводится. */
|
/**
|
||||||
|
* Распознавание отменено — текст не выводится.
|
||||||
|
* reason="user" — отмена/пустой клик пользователя: очки возвращаются в «слушаю»;
|
||||||
|
* reason="timeout" — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("stt_cancel")
|
@SerialName("stt_cancel")
|
||||||
class SttCancel : HostToGlasses
|
class SttCancel(val reason: String = "user") : HostToGlasses
|
||||||
|
|
||||||
/** Один элемент истории сессии (только user/assistant — tool/calls очкам не нужны). */
|
/** Один элемент истории сессии (только user/assistant — tool/calls очкам не нужны). */
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -221,6 +221,25 @@ class HostToGlassesTest {
|
|||||||
assertEquals("""{"type":"stt_cancel"}""", text)
|
assertEquals("""{"type":"stt_cancel"}""", text)
|
||||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
assertIs<SttCancel>(decoded)
|
assertIs<SttCancel>(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<SttCancel>(decoded)
|
||||||
|
assertEquals("timeout", decoded.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sttCancelBackwardCompatibleDefault() {
|
||||||
|
// Старое сообщение без поля reason десериализуется как "user".
|
||||||
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), """{"type":"stt_cancel"}""")
|
||||||
|
assertIs<SttCancel>(decoded)
|
||||||
|
assertEquals("user", decoded.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user