Compare commits

...

10 Commits

Author SHA1 Message Date
subochev 40edd88c1d stt(mic): вынести кадровую арифметику в micChunkBytes + регрессионные тесты MicChunkTest
- CHUNK_BYTES=3200, micChunkBytes(buf, frames): кадры read() -> байты (*2)
- 4 теста: полный чанк 3200 Б без копирования, короткий read,
  документация старого бага (copyOf(кадры) = полка), inFrameCount=1600
2026-08-24 12:23:33 +03:00
subochev fc84ba16b6 stt(mic): request frames in frames (inFrameCount), not bytes — each chunk now carries full 100 ms 2026-08-24 11:10:05 +03:00
subochev c940a7e0eb stt(mic): AudioRecord.read возвращает КАДРЫ, а не байты — не терять вторую половину каждого 100-мс чанка (впечатление 'пропускает через один') 2026-08-24 11:08:27 +03:00
subochev 22eb83fd17 stt: debug-дампы аудио-пути (очки: сырой микрофон в WAV; телефон: принятые PCM) по старту от stt_dump.marker 2026-08-24 05:58:59 +03:00
subochev c1c169036a stt: VAD-интерфейс (Sherpa боевая / Silero тестовая), PhraseRecognizer (Whisper), чистый full-text-тест на реальном Silero VAD 2026-08-24 05:43:29 +03:00
subochev 7557d7229f Очки: отмена слушания двойным тапом — SttCancel reason=cancel 2026-08-24 03:01:55 +03:00
subochev 504e66f0c6 Очки: красный индикатор записи микрофона в режиме «слушаю» 2026-08-23 21:58:36 +03:00
subochev e24caaa684 Очки: разграничение SttCancel (user/timeout) — выход из режима при тишине 30с, микрофон закрывается при потере фокуса 2026-08-23 21:17:43 +03:00
subochev eaca47c443 Очки: единый скролл диалога на оба глаза + мгновенная пауза звука телефона при вызове ассистента 2026-08-23 21:08:40 +03:00
subochev 706cc10d1e Очки: история диалога в свайпе + распознавание в LLM только по клику (касяки 1-3) 2026-08-23 20:15:21 +03:00
29 changed files with 1475 additions and 83 deletions
+73
View File
@@ -0,0 +1,73 @@
# TASK: debug-дампы аудио-пути очки→телефон (PCM-записи для диагностики STT-качества)
## Подозреваемые (по фактам разведки)
(a) убогий микрофон очков (AudioRecord MIC 16 кГц s16 mono, buf 6400, чтение 3200 Б = 100 мс);
(b) транспорт: PCM идёт как **base64 в JSON-строке** (`SttAudio.data` → kotlinx-serialization
default ByteArray-сериализатор = base64) по NEWLINE-каналу SPP/WiFi (readLines/readLine);
100-мс-чанк 3200 Б s16 → ~4300 Б base64+JSON. Потери/задержки/перепутанный порядок кусков
по шкатулке — верный кандидат «убожества»;
(c) VAD/Whisper на телефоне (настройки: VAD 0.5/0.25с/0.25с/20с, Whisper-small int8, CPU, 4
потока — на старом HONOR CPU-распознавание медленное и может «не успевать»).
## Цель
Запись сырого PCM в двух точках (очки: ДО отправки; телефон: ПОСЛЕ приёма и декода) +
сравнение: объёмы/RMS/порядок. Дальше (утром, агент+user): снять с устройств, снять
офлайн-ASR-картину, решить (a)/(b)/(c).
## Оковы
- Пути только относительные от корня репо /root/WORK/view-mate. Никаких /tmp.
- НЕ ТРОГАТЬ: HostToGlasses.kt, GlassesServer.kt, HostConnection.kt, MainActivity.kt (оба модуля).
- Правим: SttMicStream.kt (очки), SttStreamer.kt (телефон), PhoneApp.kt (телефон, НЕ охраняется), новый WavWriter.kt (очки).
## Изменения
### 1. app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt (новый)
Минимальный WAV-райтер (PCM s16le, 16000 Hz, mono):
- `class WavWriter(path: File, sampleRate: Int = 16000, channels: Int = 1)`
- `fun write(pcm: ByteArray)` — appends (header пишется один раз при создании файла).
- `close()`. Стандартный RIFF/PCM header 44 байта.
### 2. app-glasses: SttMicStream.kt
- В `start(filesDir, shouldRun)`: перед запуском потока проверить маркер
`File(filesDir, "stt_dump.marker")`. Если есть:
- создать `File(filesDir, "stt_dump_" + java.time формат HHmmss + ".wav")` через WavWriter;
- ВСЕ чанки, уходящие в onChunk (только ветка ЖИВОГО микрофона, эмуляторную не трогаем),
дописывать в WavWriter;
- log("stt", "dump микрофона: <path>").
- При `stop()` — закрыть WavWriter (runCatching).
- Все операции с dump — runCatching, сбои — только log, поток звука не прерывать.
### 3. app-phone: SttStreamer.kt
- Конструктор: добавить опциональный параметр `dumpPcmTo: File? = null`
(хвост конструктора, чтобы не сломать существующие вызовы).
- В `accept(pcm)`: если `dumpPcmTo != null` — дописать `pcm` в файл
(DataOutputStream/FileOutputStream append, raw s16le, header НЕ нужен — файл помечен как
16кГц s16 mono в логе). Все runCatching.
- В `close()`: закрыть поток дампа.
- Лог: "stt" логгер — "PCM-дампа: <path>".
### 4. app-phone: PhoneApp.kt (ensureStt(), ~строка 368)
- При создании SttStreamer передать `dumpPcmTo`:
```kotlin
val dumpMarker = File(filesDir, "stt_dump.marker")
val dumpPcm = if (dumpMarker.exists()) File(filesDir, "stt_phone_dump.raw") else null
```
(посмотреть по факту, какой context/filesDir в области видимости).
- Лог: "stt" — "PCM-дампа (телефон): <path или off>".
### 5. Прогон
- `./gradlew :app-glasses:testDebugUnitTest :app-phone:testDebugUnitTest :lib-core:jvmTest` — зелёные.
- `./gradlew :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL.
### 6. Коммит
- `git add -A && git commit -m "stt: debug-дампы аудио-пути (очки: сырой микрофон в WAV; телефон: принятые PCM) — старт по stt_dump.marker"`
- PUSH не делать.
## Для утренней диагностики (агент + user)
- На очках: `touch` маркера (adb shell run-as или в самом каталоге /sdcard/Android/data/.../files/stt_dump.marker — агент уточнит path).
- На телефоне: аналогично.
- Пользователь произносит фразы в очки → очки делают dump; после сессии — adb pull обоих файлов.
- Сравнение: (1) объём/продолжительность (должны совпадать с точностью на куски),
(2) RMS/клиппинг в очках-файле (диагноз (a)), (3) побайтовое/почанковое сравнение
очков↔телефон (диагноз (b)), (4) запустить обе записи через серверный ASR (88.125:8006) —
«убожество» воспроизводимо в оффлайне?
+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-ом (не агента).
+90
View File
@@ -0,0 +1,90 @@
# TASK-glasses-chat-fix.md — три кассия по голосовому диалогу (отчёт теста 23.08)
## Контекст
Режим общения с ассистентом: очки (app-glasses) → BT SPP → телефон (app-phone) → STT (Whisper+VAD) →
LLM → ShowText. Пользовательский тест от 23.08 нашёл три кассия. Корневые причины уже установлены
расследованием — ниже точные правки. Не трогать: аппаратное ускорение GPU на телефоне, скорость —
проверено, не трогать.
## Кассий №3 (приоритет): VAD-пауза = самоотправка в LLM
**Корень:** `PhoneApp.ensureStt()` (app-phone, ~строка 355): колбэк `onPhrase` SttStreamer делает
`assistantChannel.send(phrase)` — каждая VAD-фраза (сегмент речи, отделённый паузой ~0.25–20с)
немедленно улетает в `assistantLoop` → LLM. Человек говорит, замолчал на секунду — VAD вырезал
сегмент, и запрос уже ушёл в нейронку.
**Правка:**
1. В `PhoneApp.ensureStt` убрать из `onPhrase` отправку в `assistantChannel` (оставить только
`broadcast(SttPhrase(...))` — экрану очков показывать распознавание по-прежнему нужно).
2. Отправка в `assistantChannel` — только по `StopStt(cancel=false)` из хаба
(`GlassesServer.handle`, ветка `is StopStt`): `val full = stt?.finish() ?: ""; stt?.reset()`,
затем (если `full.isNotBlank()`) — `assistantChannel.send(full.trim())` и бродкаст.
Т.е. единственный путь текста в LLM — повторный клик пользователя.
3. В логах сохранить читаемость: `log("stt", ...)` что фраза в LLM отправлена только по клику.
Проверка логики: VAD-сегменты во время речи НИКОГДА не уходят в LLM; пустая пауза — тоже нет.
## Кассий №2: пустой клик → висящее «думаю...» 10+ с → выкидывание из режима
**Корень:** клик без голоса → `StopStt(cancel=false)` → телефон шлёт `SttDone("")` + LLM-цикл
получает пустоту (после правки №3 вообще не получит). Очки в `HostConnection`: `SttDone` →
`listening=false`, а `thinking` сбрасывается только на `ShowText`/`SttCancel` — а их нет (пустая
фраза в LLM не уходит). Оверлей висит в «думаю...» до автo-отмены тишины 30с (SttCancel) →
`listening=false, thinking=false` → оверлей прячется → пользователь видит кино (пауза).
«Выкинуло из режима» + долгое «думаю» объяснено.
**Правка:**
1. Телефон: `StopStt(cancel=false)` с пустым `full` — НЕ шлёт `SttDone`, а бродкастит `SttCancel`
(«фразы не было») и `stt?.reset()` — очки возвращаются в «слушаю» (см. п.2).
2. Очки `HostConnection`: `SttCancel` — сброс `thinking=false`, `listening` = true (вернуться в
«слушаю», микрофон открыть заново — старт `sttMic` вызвать из обработчика через
`GlassesApp.instance.mainActivity`). Заголовок оверлея автоматически вернётся к «слушаю...»
(`sttText` пустой — это OK, он при SttCancel не сбрасывается: оставил бы накопленное, если было).
Реализация — в `MainActivity`: новый хук `onSttCancelledBackToListening()` (по аналогии
`startAssistantMode` без очистки стtText).
3. В `AssistantGestures.overlayHeader` поведение не менять (логика «слушаю...» уже корректна).
Проверка: клик без голоса → очки мгновенно в «слушаю...», без «думаю» и без выхода из режима.
## Кассий №1: в свайпе видна только последняя пара сообщений, история диалога не доступна
**Корень:** протокол передаёт очки только последний ответ (`ShowText.text`) и текущую
распознаваемую фразу (`sttText`). История сессии живёт в SQLite телефона
(`ChatDao.messagesForSession`) — очкам её никто не отдаёт. Скроллить в оверлее нечего.
**Правка:**
1. `lib-core/.../protocol/HostToGlasses.kt`: новое сообщение
`@SerialName("chat_history") data class ChatHistoryMsg(val sessionId: String?, val messages: List<ChatHistoryItem>)`,
где `ChatHistoryItem(val role: String /*"user"|"assistant"|"tool"*/, val content: String)`
(только user/assistant; tool-сообщения и toolCalls не нужны очкам — фильтр на телефоне).
2. `GlassesServer` (tелефон): на `StopStt(cancel=false)` с непустым full — ПОСЛЕ отправки в
LLM-цикл (или на `Welcome` — при подключении очков, если сессия активна) бродкаст
`ChatHistoryMsg` с историей текущей/активной сессии (`chatDao.messagesForSession`,
`chatDao.latestSessionId()`), роль user/assistant, content (tool-контент не передавать).
Точка отправки — после показа ответа, чтобы в истории был и ответ ассистента:
простейший вариант — бродкаст истории в `assistantLoop` сразу после `broadcast(ShowText(answer))`.
3. Очки: `GlassesApp` — `chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())`
(`ChatEntry(role, text)`); `HostConnection` на `ChatHistoryMsg` — `filter { role == "user" ||
role == "assistant" }`, сохранить. Новый `ShowText` не должен стирать историю (это последнее
сообщение, оно уже в истории).
4. `AssistantOverlay`: вместо пары «sttText + assistantText» — список `chatHistory`:
«я: ...» (user, правее/одним цветом) и «ассистент: ...» (вторым цветом), один
`verticalScroll` на весь список, автоскролл к концу при новом `ShowText`
(`LaunchedEffect(lastHistoryVersion) { scrollState.animateScrollTo(scrollState.maxValue) }`).
Режимы «слушаю»/«думаю» заголовок — как сейчас; свайпы — те же `ScrollAnswer` дельты на общий
скролл (механизм `answerScrollDelta` сохранить, теперь он скроллит список, не одно сообщение).
5. `AssistantGestures` — без изменений (матрица жестов не меняется).
## Ограничения
- Код только Kotlin. Не трогать: `LocalLlm.kt` (GPU-конфиг, uses-native-library), скорость,
клавиатуру телефона, автоименование сессий.
- Протокол — backward-совместимый: новые сообщения с `@SerialName`, старые клиенты не падают
(ксеролайн — ignore-неизвестные в `protocolJson`?). Проверить флаг `ignoreUnknownKeys`
в `ProtocolJson`/`protocolJson` — если не включён, включить (очки новой версии → телефон старой
и обратно).
- Unit-тесты: жесты не менять, но добавить/поправить, если `AssistantState` меняется.
- Сборка: `./gradlew :app-glasses:assembleDebug :app-phone:assembleDebug :lib-core:build` —
компиляция и тесты без ошибок.
## Определяющий тест (прогон на устройстве позже)
1. Кликаю → говорю → пауза 2с → продолжаю → клик. LLM получает ВСЮ фразу целиком, один раз.
(Логи: одна `ВЕСЬ ТЕКСТ`, один `получена фраза`.)
2. Клик без голоса → «слушаю...» без «думаю», остаюсь в режиме.
3. Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу.
+49
View File
@@ -0,0 +1,49 @@
# TASK-glasses-scroll-fix.md — фикс скролла диалога на очках + пауза звука телефона
## Контекст
После касяков 1–3 (коммит 706cc10) тест на железе выявил два новых дефекта (отчёт 23.08 21:02-21:03):
**Дефект А (глаза скроллят по-разному + «всё зависло»):**
Пользователь: «текст будто бы по-разному скроллится... скролл по-разному работал для левого и правого глаза. мб ошибка связана с тем, что текст рисуется дважды (для каждого глаза)».
Корень (установлен расследованием):
- `Binocular()` (app-glasses/.../ui/ViewUtils.kt) вызывает content **ДВАЖДЫ**: левый глаз — `PositionedBox(x=0)` с `BinocularScreenSide=LEFT`, правый — `PositionedBox(x=parentWidth)` с `BinocularScreenSide=RIGHT`.
- `AssistantOverlayContent` (app-glasses/.../ui/AssistantOverlay.kt) внутри этого контента создаёт:
- `rememberScrollState()` — **два независимых экземпляра** (по одному на глаз);
- `LaunchedEffect(chatHistory.size, state.assistantText) { animateScrollTo(maxValue) }` — **два экземпляра**, анимируют каждый СВОЙ scrollState;
- `LaunchedEffect(Unit) { app.answerScrollDelta.collect { ... } }` — **два коллектора** на один общий `answerScrollDelta`: первый читает дельту и обнуляет аккумулятор, второй в это же время видит 0 или уже обработанную дельту → скролл расходится между глазами, автоскролл и свайпы дерутся → визуальная «каша» и ощущение зависания.
**Дефект Б (звук на телефоне после паузы на очках):**
Пользователь: «я нажал три раза (вызвать ассистента). На очках фильм остановился, а на телефоне ещё какое-то время звук шёл».
Корень: `startAssistantMode()` (app-glasses/.../MainActivity.kt, ~строка 251) вызывает `movieController.pauseIfPlaying()` (пауза ExoPlayer очков), но **не вызывает `hostConnection.sendPlaybackPositionNow()`**. Телефон ставит аудио-плеер на паузу ТОЛЬКО по `PlaybackPosition` (`PhoneApp.onPlaybackPosition → audioSync.sync`), а статус шлётся раз в ~5 секунд. В `onPause`/`SCREEN_OFF`/`display OFF` очки такой вызов делают — в `startAssistantMode` забыли.
## Правки (только app-glasses; всё остальное НЕ трогать)
### 1. AssistantOverlay.kt — один scrollState и один коллектор на оба глаза
Сейчас: `AssistantOverlay()` → `overlayBox(app, state, chatHistory)` → `Binocular { ... AssistantOverlayContent(state, chatHistory) }`, а `AssistantOverlayContent` сама создаёт scrollState и LaunchedEffect'ы — поэтому они дублируются на каждый глаз.
Требование: **поднять состояние и эффекты на уровень ВЫШЕ Binocular** (в `AssistantOverlay()`), чтобы они существовали в единственном экземпляре:
1. В `AssistantOverlay()` после проверки `overlayVisible`:
- `val scrollState = rememberScrollState()` — ОДИН экземпляр на оба глаза;
- ОДИН `LaunchedEffect(chatHistory.size, assistantText)` — автоскролл к концу: `if (hasHistory || assistantText.isNotBlank()) scrollState.animateScrollTo(scrollState.maxValue)`;
- ОДИН `LaunchedEffect(Unit)` — коллектор `app.answerScrollDelta`: логика как сейчас (`scrollOffset`, обнуление аккумулятора, `animateScrollTo(target, tween(150))`).
2. `AssistantOverlayContent` сделать **чистой отрисовкой**: параметры `(state, chatHistory, scrollState)`, ВНУТРИ НЕ создавать ни rememberScrollState, ни LaunchedEffect'ов, ни коллекторов. Только Column + header + список `chatHistory` (как сейчас: «я: ...» белым / «ассистент: ...» `0xFFB0BEC5`, verticalScroll(scrollState)) + fallback на `assistantText` при пустой истории.
3. `overlayBox` — передавать `scrollState` в `AssistantOverlayContent` (сигнатуры поправить).
Проверка логики: в `Binocular` контент вызывается дважды, но scrollState/LaunchedEffect теперь живут вне него — оба глаза рисуют одно и то же состояние, гонки нет.
### 2. MainActivity.kt — мгновенная отправка позиции при вызове ассистента
В `startAssistantMode()` после `app.movieController.pauseIfPlaying()` добавить:
```kotlin
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
```
(по образцу `onPause`/`SCREEN_OFF`: пауза + немедленный статус, чтобы телефон тут же поставил звук на паузу).
### 3. Сборка, тесты, коммит
- `./gradlew :lib-core:build :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL, тесты проходят.
- `git add -A && git commit -m 'Очки: единый скролл диалога на оба глаза + мгновенная пауза звука телефона при вызове ассистента'` (БЕЗ push).
## КРИТИЧЕСКИЕ ПРАВИЛА
- workspace = /root/WORK/view-mate. Пути ТОЛЬКО относительные от корня (app-glasses/...). НИКОГДА /root/WORK/app-glasses (без view-mate) — external_directory, permission-guard отклонит.
- НЕ трогать: app-phone, lib-core, LocalLlm.kt (GPU), скорость, клавиатуру, автоименование, жесты (AssistantGestures.kt), матрицу режимов.
- Коммит одного файла оверлея + одного метода MainActivity.
- В конце — краткий отчёт: файлы, что изменено, результат сборки/тестов.
+50
View File
@@ -0,0 +1,50 @@
# TASK-red-dot-recording.md — индикатор записи микрофона на очках
## Контекст
Пользователь просит: когда на очках идёт запись микрофона (режим «слушаю», `listening=true`),
показывать в углу экрана красный кружок (индикатор записи, как у камер/диктофонов).
Только очки (app-glasses), только UI. Через опен-код, как всегда.
## Где это живёт
- `app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt`
- `AssistantOverlay()` собирает `state` (listening/thinking/sttText/assistantText), затем
`overlayBox(app, state, chatHistory, scrollState)`.
- `overlayBox()` внутри `Binocular(scale=false)` рисует `Box` с чёрным затемнением
`Color.Black.copy(alpha = 0.72f)` и в нём `AssistantOverlayContent(...)`.
- Binocular рендерит контент дважды (левый/правый глаз) — кружок появится в каждом глазу,
это правильно (в каждой половине экрана свой индикатор).
## Правка
В `overlayBox()`: внутри внешнего `Box` (тот, что с `.background(Color.Black.copy(alpha = 0.72f))`)
добавить поверх контента, когда `state.listening == true`, маленький красный круг в
верхнем правом углу (по центру своей половины экрана, т.е. с отступом от края):
```kotlin
if (state.listening) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 12.dp, end = 12.dp)
.size(16.dp)
.background(Color(0xFFFF3B30), CircleShape),
)
}
```
Нужные импорты добавить: `androidx.compose.foundation.shape.CircleShape`,
`androidx.compose.foundation.layout.size`, `Modifier.align` (уже есть `Alignment`).
Проверить, что `Box`-контейнер позволяет `align` (должен, т.к. это BoxScope).
Кружок виден только в режиме «слушаю» (listening=true): при «думаю...» и при показе ответа —
не показывается (микрофон уже закрыт). Это соответствует смыслу: индикатор = идёт запись.
Никаких изменений протокола, телефона, жестов, логики — только отрисовка индикатора.
## Сборка, тесты, коммит
- `./gradlew :lib-core:build :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL.
- `git add -A && git commit -m 'Очки: красный индикатор записи микрофона в режиме «слушаю»'` (БЕЗ push).
## КРИТИЧЕСКИЕ ПРАВИЛА
- workspace = /root/WORK/view-mate. Пути ТОЛЬКО относительные от корня (app-glasses/...).
НИКОГДА /root/WORK/app-* (без view-mate) — external_directory, permission-guard отклонит.
- НЕ трогать: app-phone, lib-core, LocalLlm.kt (GPU), скорость, клавиатуру, автоименование,
AssistantGestures.kt, матрицу жестов, HostConnection.kt, MainActivity.kt, GlassesApp.kt.
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
+65
View File
@@ -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 (скролл уже починен).
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
+196
View File
@@ -0,0 +1,196 @@
# TASK: VAD-интерфейс (стриминг) + две реализации + PhraseRecognizer + «чистый» full-text-тест
## Контекст (факты, проверены)
- `app-phone/.../phone/stt/SttStreamer.kt` — стриминг: `accept(pcm: ByteArray)` (s16le 16 кГц mono)
→ `s16leToFloat` → окна по 512 → sherpa `vad.acceptWaveform(front()/pop()/flush())` →
`stt.recognize(samples)` (WhisperStt, нативка) → `full` → `finish()` возвращает весь текст.
- `WhisperStt` — класс с методом `recognize(samples: FloatArray): String` (sherpa OfflineRecognizer).
- Батч-`VadSegmenter` (оффлайн, SttDebug) — Android-only, ЭТОТ МОДУЛЬ НЕ ТРОГАЕМ (п. 7).
- Новая библиотека (mavenLocal, уже опубликована!): `pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT`:
`pw.binom.ai.voice.vad.VadInstance(16000)` : `java.lang.AutoCloseable`,
`windowSizeSamples` (512 при 16 кГц), `processingWindow(FloatArray|FloatBuffer): Float`
(вероятность речи на окно 512 сэмплов, истинный Silero на ONNX 1.16.0; реальный onnx-файл
`silero_vad.onnx` ВНУТРИ jar; нативка linux-x64 в зависимости onnxruntime — поднимается на JVM).
Зависимости jar: kotlin-stdlib + onnxruntime + JTransforms (pw.binom УБРАН — проверено по .module).
- `settings.gradle.kts` view-mate уже содержит `mavenLocal()` в dependencyResolutionManagement ✓.
- Фикстур: `voice-samples/masha-anton.wav` — 16 кГц, mono, s16le, 60.2 с (в корне репо).
## Правки
### 1. Новый интерфейс (MAIN): `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/StreamingVad.kt`
```kotlin
package pw.binom.viewmate.phone.stt
/** Сегмент речи: [startSample, startSample+samples.size) в сэмплах 16 кГц. */
class VadSegment(val startSample: Int, val samples: FloatArray)
/**
* Стриминговый VAD: подача окнами по windowSizeSamples → готовые сегменты.
* Реализации: [SherpaStreamingVad] (боевая, Android), SileroStreamingVad (тесты, JVM).
*/
interface StreamingVad {
val windowSizeSamples: Int
/** Подать окно; готовые сегменты (если появились) забираются popSegment(). */
fun acceptWindow(window: FloatArray)
/** Последний готовый сегмент (копию caller должен делать сам, если нужно удерживать). */
fun popSegment(): VadSegment?
/** Закрыть текущий незавершённый сегмент (если есть). */
fun flush()
fun close()
}
```
### 2. Новая боевая реализация (MAIN): `.../phone/stt/SherpaStreamingVad.kt`
Обёртка над `com.k2fsa.sherpa.onnx.Vad` (именно текущий код из `SttStreamer.vadConfig()`):
SileroVadModelConfig(model=modelPath, threshold=0.5f, minSilenceDuration=0.25f,
minSpeechDuration=0.25f, windowSize=512, maxSpeechDuration=20f), sampleRate=16000,
numThreads=1, provider="cpu".
- `acceptWindow(w)` = `vad.acceptWaveform(w)`.
- `popSegment()`: `if (vad.empty()) null else { val s = vad.front(); val out = VadSegment(s.start, s.samples); vad.pop(); out }`
- `flush()` = `vad.flush()` (после — сегменты через popSegment).
- `close()` = `runCatching { vad.release() }`.
- Класс закрывает AutoCloseable, НЕ наследуя ничего (close — обычная сигнатура).
### 3. Конструктор `SttStreamer` (MAIN, правим SttStreamer.kt)
- Было: `SttStreamer(stt: WhisperStt, vadModelPath: String, onPhrase, onSilence30s)` +
поле `private val vad: Vad = Vad(null, vadConfig())`.
- Стало: `SttStreamer(stt: PhraseRecognizer, vad: StreamingVad, onPhrase: (String,String)->Unit, onSilence30s: () -> Unit)`.
Убрать `vadConfig()` и импорт sherpa `Vad/VadModelConfig/SileroVadModelConfig` из SttStreamer
(теперь они в SherpaStreamingVad).
- `feedWindow(window)` → `vad.acceptWindow(window); var seg = vad.popSegment(); while (seg != null) { recognizeSegment(seg.samples.copyOf()); seg = vad.popSegment() }`
- `finish()` → `vad.flush(); var seg = vad.popSegment(); while (seg != null) { recognizeSegment(seg.samples.copyOf()); seg = vad.popSegment() }; (ожидание lastFuture как сейчас)`
- `resetLocked()` → `vad.flush(); var seg = vad.popSegment(); while (seg != null) { seg = vad.popSegment() }; (остальное как сейчас)`
- `close()` → `vad.close(); executor.shutdownNow(); watchdog.interrupt()` (как сейчас, только vad.close() вместо vad.release()).
- Всё остальное (lock, pending, session, deadline, s16leToFloat) — БЕЗ ИЗМЕНЕНИЙ.
### 4. Новая реализация PhraseRecognizer (MAIN, новый файл `.../phone/stt/PhraseRecognizer.kt`)
```kotlin
package pw.binom.viewmate.phone.stt
/** Распознавание одной фразы (16 кГц mono PCM) в текст. */
interface PhraseRecognizer {
fun recognize(samples: FloatArray): String
}
```
`WhisperStt` (MAIN) — добавить реализацию: `class WhisperStt(...) : PhraseRecognizer` (метод уже
совпадает по сигнатуре; только добавить интерфейс + import).
### 5. PhoneApp.kt — `ensureStt()` (MAIN, правка минимальная)
Было:
```kotlin
val s = SttStreamer(stt = whisper, vadModelPath = vadModel.absolutePath, onPhrase=..., onSilence30s=...)
```
Стало:
```kotlin
val s = SttStreamer(stt = whisper, vad = SherpaStreamingVad(vadModel.absolutePath), onPhrase=..., onSilence30s=...)
```
(импорт `pw.binom.viewmate.phone.stt.SherpaStreamingVad`; остальное в PhoneApp не трогать).
### 6. Тестовые исходники (app-phone/src/test/.../phone/stt/)
- `SileroStreamingVad.kt` — реализация `StreamingVad` над `pw.binom.ai.voice.vad.VadInstance(16000)`.
Точный стриминговый постпроцесс (порядок окон, 512 сэмплов каждое; параметры те же, что
у sherpa, для поведенческого паритета: threshold 0.5, minSilence 0.25 с = 4000 сэмплов,
minSpeech 0.25 с = 4000 сэмплов, maxSpeech 20 с = 320000 сэмплов):
Состояние: `inSpeech: Boolean = false`, `segStart: Int = 0`, `lastSpeechEnd: Int = 0`,
`total: Int = 0` (обработано сэмплов), `buf = ArrayList<Float>()` (все сэмплы от начала,
для среза сегментов; 60 с ≈ 3.9 МБ — допустимо), `queue = ArrayDeque<VadSegment>()`.
`acceptWindow(w: FloatArray)`: `require(w.size == 512)`; `val p = vad.processingWindow(w)`;
`val s = total; val sEnd = s + 512; buf.addAll(w); total = sEnd`.
- if `p >= 0.5f`:
- if !inSpeech { inSpeech = true; segStart = s }
- lastSpeechEnd = sEnd
- if (lastSpeechEnd - segStart) >= 320000 { emit(); inSpeech = false }
- else:
- if inSpeech && (sEnd - lastSpeechEnd) >= 4000 {
if (lastSpeechEnd - segStart) >= 4000 { emit() }; inSpeech = false }
где `emit()`: `val seg = VadSegment(segStart, FloatArray(lastSpeechEnd - segStart){ buf[segStart + it] }); queue.addLast(seg)`.
`popSegment()`: `if (queue.isEmpty()) null else queue.removeFirst()`.
`flush()`: if (inSpeech && (lastSpeechEnd - segStart) >= 4000) emit().
`close()`: `runCatching { vad.close() }` (VadInstance — AutoCloseable).
Конструктор: `SileroStreamingVad()`, в init `private val vad = VadInstance(16000)`
(поднимает ONNX-нативку linux-x64 из jar — в JVM-юните это работает).
- `FakePhraseRecognizer.kt` — `class FakePhraseRecognizer(private val phrases: List<String> = listOf("привет"))` :
`PhraseRecognizer { override fun recognize(s: FloatArray): String = phrases[0] }` (тесту достаточно
одной фразы "привет"; при желании счётчик по index, но не требуется).
- `WavPcm.kt` (тест) — минимальный загрузчик: `fun loadPcm(path: String): ByteArray` — читает
RIFF WAV s16le mono 16000 (header + raw PCM → ByteArray), если sampleRate/channels не 16000/mono —
fail(). (Можно переиспользовать `WavReader` из main — он принимает InputStream и отдаёт
FloatArray; нам нужен именно ByteArray s16le — проще написать свой 20-строчный по факту,
т.к. WavReader отдаёт FloatArray и конверсию float→s16le обратно — глупость. Пишем свой.)
### 7. `VadSegmenter.kt` (MAIN) — НЕ ТРОГАТЬ. SttDebug — не трогаем (Android-only).
### 8. Зависимость в `app-phone/build.gradle.kts`
```kotlin
testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT")
```
(в блоке testImplementation рядом с kotlin("test")). mavenLocal() уже в settings ✓.
### 9. Восстановление full-text-теста в `app-phone/src/test/.../phone/GlassesServerTest.kt`
Заменить коммент (стр. ~197-200) на тест:
```kotlin
@Test
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
hub.sttFactory = {
SttStreamer(
stt = FakePhraseRecognizer(),
vad = SileroStreamingVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
}
val pcm = WavPcm.loadPcm("../voice-samples/masha-anton.wav")
// кидать по 3200 байт (100 мс @ 16к s16 mono)
var off = 0
while (off < pcm.size) {
val end = minOf(off + 3200, pcm.size)
hub.handle("c1", protocolJson.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end))))
off = end
}
hub.handle("c1", protocolJson.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
// ждём SttDone с непустым текстом (все «привет» от фейка по числу сегментов VAD)
val got = awaitTrue { hub.sttDoneText?.isNotBlank() ?: false } // по факту: смотреть, как
assert that SttDone получен и текст = "привет" повторён N раз (N — число сегментов, ≥1)
}
```
ВАЖНО: посмотреть по факту, как тесты этого файла ловят broadcast (есть ли у hub очередь
SttDone или шлём на ws-клиент `wsClient()` — тесты здесь через Ktor ws-клиент). Использовать
существующий паттерн `wsClient()` + `awaitTrue` — как в stopSttCancelSendsSttCancelReasonCancel (стр. 173).
Текст: `SttCancel`/`SttDone` ловятся на `hub.broadcast` → ws-клиент. Имя SttDone смотреть в
`HostToGlasses.kt` (lib-core) — это data-класс `SttDone(val text: String)` (по факту сверить).
### 10. Второй тест (ЧИСТЫЙ, без фейков — реальный VAD): `SileroVadRealAudioTest.kt` (test)
- `fun segmentsOnRealAudio()`: `SileroStreamingVad()`, кидаем весь PCM (790 656 сэмплов = 60 с)
окнами по 512 → popSegment-цикл → assert:
- segments ≥ 1,
- каждый сегмент: start >= 0, start+len <= total,
- суммарная речь в пределах [5000, 500000] сэмплов (0.3с — 31с),
- после flush() popSegment() == null.
- Это и есть «чистый тест распознавания» (VAD реальный, ONNX реальный, без pw.binom в classpath).
### 11. Прогон (ВСЁ, без исключений)
- `./gradlew :lib-core:jvmTest` — зелёные (81).
- `./gradlew :app-phone:testDebugUnitTest` — зелёные (156 + 2 новых = ~158, включая восстановленный
full-text и SileroVadRealAudio).
- `./gradlew :app-glasses:testDebugUnitTest` — 91, не сломать.
- `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — BUILD SUCCESSFUL.
- Если `../voice-samples/masha-anton.wav` не резолвится из workdir теста (gradle test working dir)
— проверить `./gradlew :app-phone:testDebugUnitTest --tests "*SileroVadRealAudio*" -i` → рабочий
каталог; если не тот — читать через `Path.of("../voice-samples/masha-anton.wav").toAbsolutePath()`.
### 12. Коммит
- `git add -A && git commit -m "stt: VAD-интерфейс (Sherpa боевая / Silero тестовая), PhraseRecognizer (Whisper), чистый full-text-тест на реальном Silero VAD"`
- НЕ push.
## Оковы
- Пути ТОЛЬКО относительные от корня /root/WORK/view-mate. Никаких /tmp.
- ЗАПРЕЩЕНО трогать: `lib-core/.../HostToGlasses.kt`, `app-phone/.../GlassesServer.kt`,
`app-glasses/.../HostConnection.kt`, `app-glasses/.../MainActivity.kt` (GlassesServer не правится
— стtFactory уже там; все правки вокруг него).
- SttStreamer.kt разрешён (сейчас правится). PhoneApp.kt — только ensureStt() (2 строки).
- В MAIN source set — НУЛЬ упоминаний `pw.binom.voice.vad` (Silero — только в test).
- `VadSegmenter.kt` (батч) не трогаем вообще.
@@ -6,6 +6,9 @@ import com.rayneo.arsdk.android.MercurySDK
import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
data class ChatEntry(val role: String, val text: String)
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
object GlassesConfig {
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
@@ -58,6 +61,13 @@ class GlassesApp : Application() {
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/**
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
*/
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/**
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
@@ -22,6 +22,8 @@ import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.net.GlassesTransport
import pw.binom.viewmate.core.net.WifiGlassesTransport
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatHistoryItem
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
@@ -340,6 +342,13 @@ class HostConnection(
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is ChatHistoryMsg -> {
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
GlassesApp.instance.chatHistory.value = msg.messages
.filter { it.role == "user" || it.role == "assistant" }
.map { ChatEntry(it.role, it.content) }
}
is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full
log("stt", "фраза: ${msg.phrase}")
@@ -351,9 +360,31 @@ class HostConnection(
}
is SttCancel -> {
log("stt", "отменено")
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = false
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 ещё нет — не могу вернуть микрофон")
}
}
}
is Shutdown -> {
@@ -258,6 +258,7 @@ class MainActivity : BaseEventActivity() {
if (app.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
app.listening.value = true
app.thinking.value = false
app.sttText.value = ""
@@ -278,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))
@@ -296,10 +298,24 @@ class MainActivity : BaseEventActivity() {
sttMic.start(filesDir) { app.listening.value }
}
/**
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
*/
fun resumeSttListening() {
val app = GlassesApp.instance
app.thinking.value = false
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
log("stt", "возврат в «слушаю» (SttCancel)")
}
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
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))
@@ -307,8 +323,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
@@ -353,11 +378,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", "экран погас — пауза")
}
@@ -5,6 +5,8 @@ import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.File
import java.io.FileInputStream
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,6 +29,9 @@ class SttMicStream(
) {
private var job: Job? = null
/** Debug-дампер сырого микрофона (открывается по маркеру stt_dump.marker). */
private var dump: WavWriter? = null
fun start(filesDir: File, shouldRun: () -> Boolean) {
job?.cancel()
val recorder = runCatching {
@@ -42,19 +47,26 @@ class SttMicStream(
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder?.release()
log("stt", "микрофон недоступен — отладка: PCM из файла")
dump = null
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
return
}
dump = openLiveMicDump(filesDir)
val dumpWriter = dump
job = scope.launch(Dispatchers.IO) {
try {
recorder.startRecording()
log("stt", "микрофон запущен (16 кГц, s16, mono)")
val buf = ByteArray(3_200)
val buf = ByteArray(CHUNK_BYTES) // = 1600 кадров (16-бит моно) = 100 мс
while (isActive && shouldRun()) {
val n = recorder.read(buf, 0, buf.size)
if (n > 0) {
val chunk = if (n == buf.size) buf else buf.copyOf(n)
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ
if (frames > 0) {
val chunk = micChunkBytes(buf, frames)
if (dumpWriter != null) {
runCatching { dumpWriter.write(chunk) }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
}
onChunk(chunk)
}
}
@@ -63,6 +75,8 @@ class SttMicStream(
} finally {
runCatching { recorder.stop() }
recorder.release()
runCatching { dumpWriter?.close() }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
log("stt", "микрофон остановлен")
}
}
@@ -71,6 +85,24 @@ class SttMicStream(
fun stop() {
job?.cancel()
job = null
runCatching { dump?.close() }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
}
/**
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
* открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда
* до отправки в onChunk (эмуляторную ветку не трогаем).
*/
private fun openLiveMicDump(filesDir: File): WavWriter? {
if (!File(filesDir, "stt_dump.marker").exists()) return null
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"))
val path = File(filesDir, "stt_dump_$stamp.wav")
return runCatching {
WavWriter(path).also { log("stt", "dump микрофона: $path") }
}
.onFailure { log("stt", "dump микрофона: ${it.message}") }
.getOrNull()
}
/**
@@ -106,6 +138,20 @@ class SttMicStream(
}
}
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
internal const val CHUNK_BYTES = 3_200
/**
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
* 16-бит моно: кадр = 2 байта. Байты = frames * 2.
* Старый баг (заделан в c940a7e): return read() (кадры) сравнивали с buf.size (байты)
* — всегда ложь → copyOf(n) резал кусок пополам (терялись 2-е 50 мс каждого 100 мс).
*/
internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
val bytes = frames * 2
return if (bytes == buf.size) buf else buf.copyOf(bytes)
}
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
val out = ByteArray(samples.size * 2)
@@ -0,0 +1,68 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Минимальный WAV-райтер (PCM s16le): 44-байтовый RIFF-заголовок пишется один раз
* при создании файла, [write] дописывает чанки, на close в заголовок дописываются
* реальные размеры (best-effort — при сбое ридер читает файл до EOF, размеры
* 0xFFFFFFFF — конвенция стриминговых WAV).
*
* Не потокобезопасен — вызывать из одного потока (поток микрофона).
*/
class WavWriter(
private val path: File,
private val sampleRate: Int = 16_000,
private val channels: Int = 1,
) {
private val out: FileOutputStream
private var dataBytes = 0L
private var closed = false
init {
out = FileOutputStream(path)
out.write(headerBytes())
}
/** Дописать чанк s16le PCM (16 кГц, mono). */
fun write(pcm: ByteArray) {
out.write(pcm)
dataBytes += pcm.size
}
/** Закрыть файл и прописать реальные размеры в заголовок (идемпотентно). */
fun close() {
if (closed) return
closed = true
out.close()
runCatching {
RandomAccessFile(path, "rw").use { raf ->
raf.seek(4)
raf.writeInt((36 + dataBytes).toInt()) // размер RIFF
raf.seek(40)
raf.writeInt(dataBytes.toInt()) // размер data
}
}
}
private fun headerBytes(): ByteArray =
ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
put("RIFF".toByteArray())
putInt(-1) // placeholder
put("WAVE".toByteArray())
put("fmt ".toByteArray())
putInt(16)
putShort(1.toShort()) // PCM
putShort(channels.toShort())
putInt(sampleRate)
putInt(sampleRate * channels * 2)
putShort((channels * 2).toShort())
putShort(16.toShort())
put("data".toByteArray())
putInt(-1) // placeholder
}.array()
}
@@ -11,7 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
@@ -25,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import pw.binom.viewmate.glasses.ChatEntry
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.overlayHeader
@@ -35,7 +39,8 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
* «ответ виден» — прокручиваемый ответ (дельта скролла от жестов — [GlassesApp.answerScrollDelta]).
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
* [GlassesApp.answerScrollDelta]).
*/
@Composable
fun AssistantOverlay() {
@@ -44,14 +49,46 @@ fun AssistantOverlay() {
val thinking by app.thinking.collectAsState()
val sttText by app.sttText.collectAsState()
val assistantText by app.assistantText.collectAsState()
val chatHistory by app.chatHistory.collectAsState()
val state = AssistantState(listening, thinking, sttText, assistantText)
if (!overlayVisible(state)) return
overlayBox(app, state)
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
// в единственном экземпляре — иначе глаза скроллят по-разному.
val scrollState = rememberScrollState()
val hasHistory = chatHistory.isNotEmpty()
// Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению.
LaunchedEffect(chatHistory.size, state.assistantText) {
if (hasHistory || state.assistantText.isNotBlank()) {
scrollState.animateScrollTo(scrollState.maxValue)
}
}
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) {
app.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
app.answerScrollDelta.value = 0f
if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150))
}
}
}
overlayBox(app, state, chatHistory, scrollState)
}
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
@Composable
private fun overlayBox(app: GlassesApp, state: AssistantState) {
private fun overlayBox(
app: GlassesApp,
state: AssistantState,
chatHistory: List<ChatEntry>,
scrollState: ScrollState,
) {
Binocular(scale = false) {
val scale by app.scale.collectAsState()
val screenWidthPx = BinocularScreenSize.current.width
@@ -66,42 +103,37 @@ private fun overlayBox(app: GlassesApp, state: AssistantState) {
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.background(Color.Black.copy(alpha = 0.72f)),
) {
AssistantOverlayContent(state)
AssistantOverlayContent(state, chatHistory, scrollState)
if (state.listening) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 12.dp, end = 12.dp)
.size(16.dp)
.background(Color(0xFFFF3B30), CircleShape),
)
}
}
}
}
@Composable
private fun AssistantOverlayContent(state: AssistantState) {
val app = GlassesApp.instance
val hasAnswer = state.assistantText.isNotBlank()
private fun AssistantOverlayContent(
state: AssistantState,
chatHistory: List<ChatEntry>,
scrollState: ScrollState,
) {
val hasHistory = chatHistory.isNotEmpty()
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
val header = overlayHeader(state)
val scrollState = rememberScrollState()
// Новый ответ приходит с обнулением answerScrollDelta (ShowText) — к началу текста.
LaunchedEffect(state.assistantText) {
scrollState.scrollTo(0)
}
// Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect],
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) {
app.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
app.answerScrollDelta.value = 0f
if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150))
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (hasAnswer) Arrangement.Top else Arrangement.Center,
verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center,
) {
if (!header.isNullOrBlank()) {
Text(
@@ -111,7 +143,28 @@ private fun AssistantOverlayContent(state: AssistantState) {
style = MaterialTheme.typography.titleMedium,
)
}
if (hasAnswer) {
if (hasHistory) {
Spacer(modifier = Modifier.height(12.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(scrollState),
) {
chatHistory.forEach { entry ->
Text(
text = if (entry.role == "user") "я: ${entry.text}" else "ассистент: ${entry.text}",
color = if (entry.role == "user") Color.White else Color(0xFFB0BEC5),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
)
}
}
} else if (fallbackAnswer) {
// Старый клиент телефона не шлёт историю: показываем одиночный ответ.
Spacer(modifier = Modifier.height(12.dp))
Column(
modifier = Modifier
@@ -120,12 +173,15 @@ private fun AssistantOverlayContent(state: AssistantState) {
.verticalScroll(scrollState),
) {
Text(
text = state.assistantText,
text = "ассистент: ${state.assistantText}",
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
)
}
}
}
}
}
@@ -0,0 +1,48 @@
package pw.binom.viewmate.glasses.stt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
/**
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
* 16-бит моно: кадр = 2 байта. Старики брали return read() как байты —
* и copyOf(кадры) резал каждый 100-мс кусок пополам (терялись 2-е 50 мс).
*/
class MicChunkTest {
@Test
fun fullChunk_all1600Frames_returnsAll3200Bytes() {
val buf = ByteArray(CHUNK_BYTES)
val out = micChunkBytes(buf, CHUNK_BYTES / 2) // 1600 кадров = полная 100 мс
assertEquals(CHUNK_BYTES, out.size, "полный чанк = 3200 байт (100 мс), а не 1600")
assertSame(buf, out, "полный чанк не копируется — реюзаем buf без аллокаций")
}
@Test
fun shortRead_800Frames_gives1600Bytes_not800() {
val buf = ByteArray(CHUNK_BYTES)
val out = micChunkBytes(buf, 800) // 800 кадров = 1600 байт
assertEquals(1_600, out.size, "байты = кадры × 2, не равны количеству кадров")
assertNotSame(buf, out, "короткий чанк копируется, иначе хвост buf — мусор")
}
@Test
fun oldLogic_lostSecondHalf_ofEachChunk() {
// Задокументированный баг: n (кадры) брался как байты.
val buf = ByteArray(CHUNK_BYTES)
val frames = recorderWouldReturn(1600) // полный 100-мс запрос вернул 1600 кадров
val oldChunk = if (frames == buf.size) buf else buf.copyOf(frames) // СТАРОЕ
assertEquals(1_600, oldChunk.size, "старая логика теряла 2-ю половину куска — отсюда «как через один»")
val newChunk = micChunkBytes(buf, frames)
assertEquals(3_200, newChunk.size, "новая логика отдаёт полные 100 мс")
}
@Test
fun framesRequested_forFull100MsIs1600() {
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)")
}
private fun recorderWouldReturn(frames: Int) = frames
}
+2
View File
@@ -111,6 +111,8 @@ dependencies {
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
testImplementation(kotlin("test"))
// Тестовый стриминговый VAD (Silero на ONNX, JVM) — из mavenLocal
testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT")
testImplementation("io.ktor:ktor-server-test-host:3.3.0")
testImplementation("io.ktor:ktor-client-websockets:3.3.0")
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
@@ -105,6 +105,14 @@ class GlassesHub(
@Volatile
var stt: SttStreamer? = null
/**
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
* на StopStt(cancel=false) с непустым текстом. VAD-фразы в LLM НЕ уходят —
* только повторный клик пользователя.
*/
@Volatile
var onStopFullText: ((String) -> Unit)? = null
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
@Volatile
var sttFactory: (() -> SttStreamer?)? = null
@@ -195,13 +203,22 @@ class GlassesHub(
is StopStt -> {
if (msg.cancel) {
log("stt", "распознавание отменено (очки)")
send(connId, SttCancel())
send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
stt?.reset()
} else {
val full = stt?.finish() ?: ""
log("stt", "ВЕСЬ ТЕКСТ: $full")
send(connId, SttDone(full))
stt?.reset()
if (full.isNotBlank()) {
send(connId, SttDone(full))
val phrase = full.trim()
log("stt", "фраза в LLM (по клику): $phrase")
onStopFullText?.invoke(phrase)
} else {
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
log("stt", "фразы не было — очки в «слушаю»")
broadcast(SttCancel(reason = SttCancel.REASON_USER))
}
}
}
}
@@ -23,6 +23,8 @@ import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatHistoryItem
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase
@@ -30,6 +32,7 @@ import pw.binom.viewmate.phone.agent.AgentToolkit
import pw.binom.viewmate.phone.agent.Assistant
import pw.binom.viewmate.phone.agent.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.Gemma4E2B
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
@@ -43,6 +46,7 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient
import pw.binom.viewmate.phone.agent.SkillRegistry
import pw.binom.viewmate.phone.agent.SkillRepository
import pw.binom.viewmate.phone.agent.ToolsetRegistry
import pw.binom.viewmate.phone.stt.SherpaStreamingVad
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
@@ -290,10 +294,25 @@ class PhoneApp : Application() {
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
server.hub.broadcast(ShowText(answer))
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
server.hub.broadcast(chatHistoryMsg())
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
}
}
/**
* История активной (или последней) сессии для очков: только роли user/assistant,
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
*/
private fun chatHistoryMsg(): ChatHistoryMsg {
val id = _activeSessionId.value ?: chatDao.latestSessionId()
if (id == null) return ChatHistoryMsg(sessionId = null, messages = emptyList())
val items = chatDao.messagesForSession(id)
.filter { it.role == ChatRole.USER || it.role == ChatRole.ASSISTANT }
.map { ChatHistoryItem(role = it.role.wire, content = it.content) }
return ChatHistoryMsg(sessionId = id.toString(), messages = items)
}
/**
* Отправка текста в ассистента из чата на телефоне (не с очков).
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
@@ -347,20 +366,27 @@ class PhoneApp : Application() {
tokensPath = tokens.absolutePath,
numThreads = 4,
)
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw")
} else {
null
}
val s = SttStreamer(
stt = whisper,
vadModelPath = vadModel.absolutePath,
vad = SherpaStreamingVad(vadModel.absolutePath),
onPhrase = { phrase, full ->
log("stt", "фраза: $phrase")
// Только показ на очках — в LLM фраза НЕ уходит (только по клику).
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
scope.launch { assistantChannel.send(phrase) }
},
onSilence30s = {
log("stt", "тишина 30 с — автo-отмена")
scope.launch { server.hub.broadcast(SttCancel()) }
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
},
dumpPcm = dumpPcm,
)
sttStreamer = s
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
return s
}
@@ -404,6 +430,7 @@ class PhoneApp : Application() {
DebugHttpServer.start(this)
nsd.publish()
server.hub.sttFactory = { ensureStt() }
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
scope.launch { assistantLoop() }
val lastPositionAt = AtomicLong(0)
@@ -0,0 +1,6 @@
package pw.binom.viewmate.phone.stt
/** Распознавание одной фразы (16 кГц mono PCM) в текст. */
interface PhraseRecognizer {
fun recognize(samples: FloatArray): String
}
@@ -0,0 +1,52 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
/**
* Боевой стриминговый VAD на нативной [Vad] (sherpa-onnx, Silero).
* Те же параметры, что были в vadConfig() SttStreamer: threshold 0.5,
* minSilence/minSpeech 0.25 с, окно 512, maxSpeech 20 с, 16 кГц, 1 поток, CPU.
*/
class SherpaStreamingVad(modelPath: String) : StreamingVad {
private val vad: Vad = Vad(
null,
VadModelConfig(
sileroVadModelConfig = SileroVadModelConfig(
model = modelPath,
threshold = 0.5f,
minSilenceDuration = 0.25f,
minSpeechDuration = 0.25f,
windowSize = 512,
maxSpeechDuration = 20f,
),
sampleRate = 16000,
numThreads = 1,
provider = "cpu",
),
)
override val windowSizeSamples: Int = 512
override fun acceptWindow(window: FloatArray) {
vad.acceptWaveform(window)
}
override fun popSegment(): VadSegment? {
if (vad.empty()) return null
val s = vad.front()
val out = VadSegment(s.start, s.samples)
vad.pop()
return out
}
override fun flush() {
vad.flush()
}
override fun close() {
runCatching { vad.release() }
}
}
@@ -0,0 +1,19 @@
package pw.binom.viewmate.phone.stt
/** Сегмент речи: [startSample, startSample+samples.size) в сэмплах 16 кГц. */
class VadSegment(val startSample: Int, val samples: FloatArray)
/**
* Стриминговый VAD: подача окнами по windowSizeSamples → готовые сегменты.
* Реализации: [SherpaStreamingVad] (боевая, Android), SileroStreamingVad (тесты, JVM).
*/
interface StreamingVad {
val windowSizeSamples: Int
/** Подать окно; готовые сегменты (если появились) забираются popSegment(). */
fun acceptWindow(window: FloatArray)
/** Последний готовый сегмент (копию caller должен делать сам, если нужно удерживать). */
fun popSegment(): VadSegment?
/** Закрыть текущий незавершённый сегмент (если есть). */
fun flush()
fun close()
}
@@ -1,13 +1,13 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.concurrent.withLock
import pw.binom.viewmate.phone.log
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
@@ -36,10 +36,11 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
* (автo-отмена, вызывается один раз за сессию).
*/
class SttStreamer(
private val stt: WhisperStt,
private val vadModelPath: String,
private val stt: PhraseRecognizer,
private val vad: StreamingVad,
private val onPhrase: (phrase: String, full: String) -> Unit,
private val onSilence30s: () -> Unit,
private val dumpPcm: File? = null,
) {
companion object {
private const val SILENCE_TIMEOUT_MS = 30_000L
@@ -47,7 +48,6 @@ class SttStreamer(
}
private val lock = java.util.concurrent.locks.ReentrantLock()
private val vad: Vad = Vad(null, vadConfig())
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "stt-recognize").apply { isDaemon = true }
}
@@ -64,6 +64,18 @@ class SttStreamer(
@Volatile
private var deadlineMs = 0L
/**
* Debug-дампа принятых PCM: сырые s16le (16 кГц, mono), без WAV-заголовка —
* формат помечен в логе. Сбой открытия не ломает распознавание.
*/
private val dumpOut: FileOutputStream? = dumpPcm?.let { file ->
runCatching {
FileOutputStream(file, true).also {
log("stt", "PCM-дампа: ${file.absolutePath} (16 кГц, s16, mono)")
}
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
}
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
private var pending = FloatArray(0)
@@ -113,6 +125,11 @@ class SttStreamer(
}
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
}
val out = dumpOut
if (out != null) {
runCatching { out.write(pcm) }
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
}
}
/**
@@ -123,10 +140,11 @@ class SttStreamer(
fun finish(): String {
lock.withLock {
vad.flush()
while (!vad.empty()) {
val seg = vad.front()
recognizeSegment(seg.samples)
vad.pop()
var seg = vad.popSegment()
while (seg != null) {
// Копия: массив может переиспользоваться после pop.
recognizeSegment(seg.samples.copyOf())
seg = vad.popSegment()
}
val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
@@ -142,8 +160,10 @@ class SttStreamer(
fun close() {
lock.withLock {
deadlineMs = 0
runCatching { vad.release() }
vad.close()
}
runCatching { dumpOut?.close() }
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
executor.shutdownNow()
watchdog.interrupt()
}
@@ -152,7 +172,10 @@ class SttStreamer(
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
runCatching { vad.flush() }
while (!vad.empty()) vad.pop()
var seg = vad.popSegment()
while (seg != null) {
seg = vad.popSegment()
}
session++
full = ""
pending = FloatArray(0)
@@ -161,12 +184,12 @@ class SttStreamer(
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
private fun feedWindow(window: FloatArray) {
vad.acceptWaveform(window)
while (!vad.empty()) {
val seg = vad.front()
// Копия: массив из нативного VAD может переиспользоваться после pop.
vad.acceptWindow(window)
var seg = vad.popSegment()
while (seg != null) {
// Копия: массив может переиспользоваться после pop.
recognizeSegment(seg.samples.copyOf())
vad.pop()
seg = vad.popSegment()
}
}
@@ -183,18 +206,4 @@ class SttStreamer(
onPhrase(phrase, newFull)
}
}
private fun vadConfig(): VadModelConfig = VadModelConfig(
sileroVadModelConfig = SileroVadModelConfig(
model = vadModelPath,
threshold = 0.5f,
minSilenceDuration = 0.25f,
minSpeechDuration = 0.25f,
windowSize = WINDOW_SIZE,
maxSpeechDuration = 20f,
),
sampleRate = 16000,
numThreads = 1,
provider = "cpu",
)
}
@@ -12,7 +12,7 @@ class WhisperStt(
private val tokensPath: String,
private val numThreads: Int = 4,
private val language: String = "ru",
) {
) : PhraseRecognizer {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
@@ -33,7 +33,7 @@ class WhisperStt(
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
fun recognize(samples: FloatArray): String {
override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
@@ -22,11 +22,20 @@ 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.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
import pw.binom.viewmate.phone.stt.SileroStreamingVad
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WavPcm
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.fail
/**
@@ -164,6 +173,67 @@ 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): реальный Silero VAD
* (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель
* даёт «привет» на каждый сегмент → очки получают SttDone с непустым текстом.
*/
@Test
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
hub.sttFactory = {
SttStreamer(
stt = FakePhraseRecognizer(),
vad = SileroStreamingVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
}
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
// кидать по 3200 байт (100 мс @ 16 кГц s16le mono)
var off = 0
while (off < pcm.size) {
val end = minOf(off + 3200, pcm.size)
send(json.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end))))
off = end
}
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
val frame = incoming.receive() as Frame.Text
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
val done = assertIs<SttDone>(msg)
assertTrue(done.full.isNotBlank())
}
}
// ---------- помощники ----------
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
@@ -0,0 +1,9 @@
package pw.binom.viewmate.phone.stt
/** Тестовый [PhraseRecognizer]: любая фраза → "привет" (первая строка списка). */
class FakePhraseRecognizer(
private val phrases: List<String> = listOf("привет"),
) : PhraseRecognizer {
override fun recognize(samples: FloatArray): String = phrases[0]
}
@@ -0,0 +1,77 @@
package pw.binom.viewmate.phone.stt
import pw.binom.voice.vad.VadInstance
/**
* Тестовый стриминговый VAD на реальном Silero (JVM, ONNX через [VadInstance]).
* Постпроцесс — поведенческий паритет с боевой SherpaStreamingVad:
* threshold 0.5; minSilence = minSpeech = 0.25 с = 4000 сэмплов;
* maxSpeech = 20 с = 320 000 сэмплов; окно 512 сэмплов.
*
* ВНИМАНИЕ: `buf` хранит все сэмплы с начала стрима (60 с ≈ 3.9 МБ) —
* для тестов допустимо, для боевого кода не предлагать.
*/
class SileroStreamingVad : StreamingVad {
private val vad = VadInstance(16000)
private var inSpeech = false
private var segStart = 0
private var lastSpeechEnd = 0
private var total = 0 // обработано сэмплов
private val buf = ArrayList<Float>() // все сэмплы от начала, для среза сегментов
private val queue = ArrayDeque<VadSegment>()
override val windowSizeSamples: Int = 512
override fun acceptWindow(window: FloatArray) {
require(window.size == 512) { "Окно должно быть 512 сэмплов, подано " + window.size }
val p = vad.processingWindow(window)
val s = total
val sEnd = s + 512
for (w in window) {
buf.add(w)
}
total = sEnd
if (p >= 0.5f) {
if (!inSpeech) {
inSpeech = true
segStart = s
}
lastSpeechEnd = sEnd
// maxSpeech: 20 с — сегмент принудительно закрывается.
if (lastSpeechEnd - segStart >= 320_000) {
emit()
inSpeech = false
}
} else if (inSpeech && sEnd - lastSpeechEnd >= 4000) {
// minSilence: 0.25 с тишины после речи — сегмент готов.
if (lastSpeechEnd - segStart >= 4000) {
emit()
}
inSpeech = false
}
}
override fun popSegment(): VadSegment? {
if (queue.isEmpty()) return null
return queue.removeFirst()
}
override fun flush() {
if (inSpeech && lastSpeechEnd - segStart >= 4000) {
emit()
}
}
override fun close() {
runCatching { vad.close() }
}
/** Готовый сегмент: срез из общего буфера [segStart, lastSpeechEnd). */
private fun emit() {
val len = lastSpeechEnd - segStart
val seg = VadSegment(segStart, FloatArray(len) { buf[segStart + it] })
queue.addLast(seg)
}
}
@@ -0,0 +1,63 @@
package pw.binom.viewmate.phone.stt
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* «Чистый» тест распознавания: реальный Silero VAD (ONNX через vad-jvm) +
* реальная аудио-фикстура voice-samples/masha-anton.wav.
* Без pw.binom.viewmate в classpath — только библиотека VAD и тестовый код.
*/
class SileroVadRealAudioTest {
@Test
fun segmentsOnRealAudio() {
val vad = SileroStreamingVad()
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
val n = pcm.size / 2 // сэмплов 16 кГц
assertTrue(n >= 512, "PCM фикстуры пуст")
// Весь PCM → окнами по 512 (хвост < 512 дополняем нулями — тишиной).
val total = ((n + 511) / 512) * 512 // обработано сэмплов на VAD
val segments = mutableListOf<VadSegment>()
for (i in 0 until total step 512) {
val window = FloatArray(512)
for (j in 0 until 512) {
val idx = i + j
if (idx < n) {
val lo = pcm[idx * 2].toInt() and 0xff
val hi = pcm[idx * 2 + 1].toInt() and 0xff
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
window[j] = s / 32768f
}
}
vad.acceptWindow(window)
var seg = vad.popSegment()
while (seg != null) {
segments.add(seg)
seg = vad.popSegment()
}
}
assertTrue(segments.size >= 1, "сегментов не найдено (VAD не нашёл речь в 60-секундной фикстуре)")
var speechSamples = 0
for (seg in segments) {
assertTrue(seg.startSample >= 0, "start < 0 в сегменте ${seg.startSample}")
assertTrue(
seg.startSample + seg.samples.size <= total,
"сегмент выходит за конец потока: start=${seg.startSample}, len=${seg.samples.size}, total=$total",
)
speechSamples += seg.samples.size
}
assertTrue(
speechSamples in 5000..500_000,
"суммарная речь ${speechSamples} сэмплов вне ожидаемого [5000..500000] " +
"(${speechSamples / 16000.0}s вне 0.3–31с), сегментов=${segments.size}",
)
vad.flush()
assertTrue(vad.popSegment() == null, "после flush() остались сегменты")
vad.close()
}
}
@@ -0,0 +1,72 @@
package pw.binom.viewmate.phone.stt
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Минимальный загрузчик RIFF/WAVE для тестов: s16le, 16 кГц, mono.
* (Свой, а не WavReader из main — тот отдаёт FloatArray, а нужно как раз
* сырые s16le-байты PCM.)
*/
object WavPcm {
/** Абсолютный путь к фикстуре voice-samples/masha-anton.wav (корень репо). */
fun fixture(): File {
// working dir юнит-теста gradle — каталог модуля (app-phone) или корень;
// ищем voice-samples/ вверх от user.dir.
var dir = File(System.getProperty("user.dir")).absoluteFile
while (true) {
val cand = File(dir, "voice-samples/masha-anton.wav")
if (cand.isFile) return cand
dir = dir.parentFile ?: break
}
return File("voice-samples/masha-anton.wav").absoluteFile
}
/** PCM s16le, 16 кГц, mono. Если формат не тот — fail(). */
fun loadPcm(path: String): ByteArray {
val f = File(path)
check(f.isFile) { "WAV не найден: $path" }
val b = f.readBytes()
check(b.size > 44) { "WAV: файл короче заголовка" }
fun str(off: Int, len: Int) = String(b.copyOfRange(off, off + len))
check(str(0, 4) == "RIFF") { "WAV: не RIFF" }
check(str(8, 4) == "WAVE") { "WAV: не WAVE" }
fun i16(off: Int) = ByteBuffer.wrap(b, off, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
fun i32(off: Int) = ByteBuffer.wrap(b, off, 4).order(ByteOrder.LITTLE_ENDIAN).int
var off = 12
var audioFormat = 0
var channels = 0
var sampleRate = 0
var bitsPerSample = 0
var dataOff = -1
var dataLen = 0
while (off + 8 <= b.size) {
val id = str(off, 4)
val len = i32(off + 4)
when (id) {
"fmt " -> {
audioFormat = i16(off + 8)
channels = i16(off + 10)
sampleRate = i32(off + 12)
bitsPerSample = i16(off + 22)
}
"data" -> {
dataOff = off + 8
dataLen = len
break
}
}
off += 8 + len + (len and 1) // чанки выравниваются по чётным байтам
}
check(audioFormat == 1) { "WAV: не PCM (format=$audioFormat)" }
check(channels == 1) { "WAV: каналов=$channels, ожидался mono" }
check(sampleRate == 16000) { "WAV: частота=$sampleRate, ожидалась 16000" }
check(bitsPerSample == 16) { "WAV: битов/сэмпл=$bitsPerSample, ожидалось 16" }
require(dataOff >= 0) { "WAV: нет чанка data" }
val end = minOf(b.size, dataOff + dataLen)
return b.copyOfRange(dataOff, end)
}
}
@@ -91,10 +91,32 @@ data class SttPhrase(val phrase: String, val full: String) : HostToGlasses
@SerialName("stt_done")
data class SttDone(val full: String) : HostToGlasses
/** Распознавание отменено (двойной клик или 30 с тишины) — текст не выводится. */
/**
* Распознавание отменено — текст не выводится.
* reason="user" — пустой клик без голоса: очки возвращаются в «слушаю»;
* reason="cancel" — явная отмена диктовки двойным тапом: очки закрывают микрофон,
* listening=false, thinking=false, остаются в режиме (показывают последний ответ);
* reason="timeout" — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима.
*/
@Serializable
@SerialName("stt_cancel")
class SttCancel : 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
@SerialName("chat_history_item")
data class ChatHistoryItem(val role: String, val content: String)
/** История сессии для очков (показ всего диалога со скроллом свайпами). */
@Serializable
@SerialName("chat_history")
data class ChatHistoryMsg(val sessionId: String?, val messages: List<ChatHistoryItem>) : HostToGlasses
/** Полное выключение приложения на очках (по команде с телефона). */
@Serializable
@@ -221,5 +221,48 @@ class HostToGlassesTest {
assertEquals("""{"type":"stt_cancel"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
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
fun chatHistoryRoundTrip() {
val msg: HostToGlasses = ChatHistoryMsg(
sessionId = "s-1",
messages = listOf(
ChatHistoryItem(role = "user", content = "привет"),
ChatHistoryItem(role = "assistant", content = "здравствуй"),
),
)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals(
"""{"type":"chat_history","sessionId":"s-1","messages":[""" +
"""{"role":"user","content":"привет"},""" +
"""{"role":"assistant","content":"здравствуй"}]}""",
text,
)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<ChatHistoryMsg>(decoded)
assertEquals(2, decoded.messages.size)
assertEquals("user", decoded.messages[0].role)
assertEquals("привет", decoded.messages[0].content)
assertEquals("assistant", decoded.messages[1].role)
}
}