Compare commits

45 Commits

Author SHA1 Message Date
subochev ac56e70283 stt(phone): VAD удалён (резал рваный микрофон очков) — полный буфер + trimEdges + хвост 120с + авто-финиш по тишине 30с (opencode по ТЗ) 2026-08-25 03:45:42 +03:00
subochev 4e60e11d24 stt: дамп закрывает цикл в finally (нет Stream Closed при стопе) 2026-08-25 03:13:27 +03:00
subochev b0baa19e7d stt: полный поток без VAD (резал речь на рваном ARGT78) — whisper распознаёт сырой поток (проверено: 1-10 идеально), усиление ×3, сырой дамп 2026-08-25 03:12:04 +03:00
subochev d34d15ecef stt(dump): сырой WAV всего потока микрофона (до усиления/VAD) — что реально слышат очки 2026-08-25 03:00:47 +03:00
subochev 19fe1b34bc stt(mic): возврат на 16 кГц (48 кГц молчит на ARGT78); VAD на сыром + gain ×5 остаются 2026-08-25 02:59:00 +03:00
subochev 1009713a28 stt(mic): 48 кГц как pair-view + VAD на сыром сигнале + усиление ×5 без клиппа + FIR-даунсэмпл 48→16 2026-08-25 02:55:42 +03:00
subochev 0f235da0ed Высота — только для видео: ассистент/коннект-экран следуют масштабу, но всегда у верха 2026-08-25 02:43:45 +03:00
subochev 74701a2108 Панель статуса сдвигается к носу вместе с контейнером (panelOffsetX): центр панели = центр контейнера 2026-08-25 02:39:43 +03:00
subochev 4f4a551c91 Панель времени/заряда: масштабируется как контент, но прибита к низу экрана 2026-08-25 02:35:24 +03:00
subochev 80236902ba Панель времени/заряда: шрифт фиксированный (scale=1f) — не сжимается вместе с масштабом контента 2026-08-25 02:31:28 +03:00
subochev d113ffd744 Панель времени/заряда: оверлей-слой поверх контента, у низа экрана — стабильно при любом масштабе 2026-08-25 02:28:00 +03:00
subochev 8958370507 Панель времени/заряда — вне контейнера, всегда у верха экрана (не повисает между верхом и видео при vertical=0) 2026-08-25 02:24:57 +03:00
subochev 883fd8e5e4 Высота: видео в контейнере прижимается к верху/низу по vertical (Row-центрирование съедало вертикаль) 2026-08-25 02:15:48 +03:00
subochev 6d22f56bd8 Регулятор «Высота» экрана очков (0..1, с учётом масштаба): SetVertical по протоколу, offset Y в кино/коннект/оверлее, слайдер на пульте 2026-08-25 02:11:01 +03:00
subochev b17b5bf424 stt(glasses): VAD на очках (WebRTC, схема pair-view) — речевые сегменты с пре-роллом, межфразовая тишина не шлётся 2026-08-25 01:51:57 +03:00
subochev d78ed4429e stt(mic): микрофон на 48 кГц (нативная частота) + даунсемплинг 48→16 — рваность ARGT78 давала ровные провалы 100 мс 2026-08-25 01:39:12 +03:00
subochev 032424f513 Mercury: enable heartbeat on connect (keeps link alive), autolock endpoint 2026-08-25 01:15:00 +03:00
subochev 4556d3b6e4 Mercury: verify handshake echo, autolock endpoint, ringInfo-safe status 2026-08-25 00:54:31 +03:00
subochev b40e44c653 DebugHttpServer: Mercury BLE-эндпоинты (/mercury/status|connect|disconnect|screen/off|screen/on) 2026-08-24 23:24:07 +03:00
subochev d674b31a81 Внедрение rayneo-mercury: зависимость + MercuryPanel (BLE-тест: погасить/включить экран) 2026-08-24 23:19:37 +03:00
subochev c222a4ac76 stt(phone): полный текст по клику — whisper на всём буфере, VAD только для промежуточных фраз 2026-08-24 22:11:08 +03:00
subochev e21c27b0ee stt(dump): WAV-заголовок — размеры little-endian (плееры открывают дампы) 2026-08-24 21:36:53 +03:00
subochev 0d79b5aa33 stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза) 2026-08-24 20:57:06 +03:00
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
subochev 51049d56a8 Чат: автоименование сессии из первого сообщения
- Assistant.processInSession: безымянная сессия получает имя из
  первого сообщения (обреза 30 симв.), «беседка» — пустое
- ChatScreen: периодическая перезагрузка подхватывает и список
  сессий — шапка обновляет название без ручного переключения
2026-08-23 17:40:12 +03:00
subochev ce2d9c5448 Чат: клавиатура не вырезает верх окна (adjustResize + imePadding)
- MainActivity: windowSoftInputMode=adjustResize — окно сжимается,
  а не панорамируется вверх при появлении IME
- ChatScreen: Modifier.imePadding() — контент честно ужимается
  под высоту клавиатуры
2026-08-23 17:30:40 +03:00
subochev d585ddf576 Локальная LLM: конфиг автора + uses-native-library в <application>
- AndroidManifest: три uses-native-library (libOpenCL/libvndksupport/libcdsprpc)
  ВНУТРИ <application> — aapt2 8.9.2 отбрасывает тег на уровне <manifest>
- LocalLlm: enableSpeculativeDecoding=true, GPU/GPU/CPU бэкенды (как у автора)
- DebugHttpServer: HTTP-отладка ассистента без UI
- Log/PhoneApp: мелкие правки

Подтверждено: движок поднимается, локальный чат отвечает на HONOR AXGL024B05001337
2026-08-23 17:25:55 +03:00
subochev 75496dd35d Фикс локальной LLM: таймаут 300с для LOCAL (инициализация движка ~17с + генерация), CPU-бэкенд без спекуляции (MTP виснет на CPU, GPU-делегат падает), диагностика chat()
Проверено на железе (HONOR FCP-N49): Gemma 4 E2B отвечает
«Здравствуйте! Чем я могу вам помочь с вашими очками Viewmate?»
(15 чанков, 61 символ, ~19с генерации).
2026-08-23 04:38:02 +03:00
subochev 7527cf8848 LLM: интерфейс + две реализации (Qwen удалённо / Gemma 4 E2B локально) + переключатель
- LlmClient интерфейс; RemoteLlmClient (OkHttp, llm.binom.pw) и LocalLlmClient (LiteRT-LM, Gemma 4 E2B)
- LocalLlm: Gemma4E2B (SHA-256, GPU/CPU-бэкенды), buildLocalLlmPrompt (SYSTEM→systemInstruction, TOOL→user), GemmaModelDownloader (стриминг, прогресс, фолбэк источников), спекулятивное декодирование
- LlmPrefs (персистентность выбора), PhoneApp: фабрика по llmChoice, пересоздание ассистента, скачивание/отмена
- UI: GlassesInfoScreen — выбор сервер/локальная + прогресс; ChatScreen — индикатор активной модели
- Тесты: +12 (LlmLocalTest) — phone 154 зелёных
2026-08-23 02:52:22 +03:00
subochev 2d6ca1e097 План: локальная модель на телефоне (Gemma 4 E2B, LiteRT LM) — в очереди, ждёт отмашки
- Источник: vayun-mathur/Modern-Apps openassistant (клон ~/WORK/modern-apps/)
- Интерфейс LLM + 2 реализации: Qwen3.8 (сеть) и Gemma E2B (локально, оффлайн)
- Разбор скорости: GPU-бэкенд + MTP-спекулятивный декодинг, 20-35 ток/с на 8 Gen 3
- Реализация НЕ начата — по правилу пользователя «без отмашки не начинай»
2026-08-23 02:00:21 +03:00
subochev f75e672819 Очки: жесты и режимы общения с ассистентом (по docs/gestures-and-modes.md)
- AssistantMode: NONE/LISTENING/THINKING/ANSWER + чистая gestureDecision (матрица жестов)
- Клик в «ответ виден» = новая фраза; DoubleClick = выход (кино на паузе); свайпы = скролл
- Плавный скролл ответа: SlideContinuous delta → answerScrollDelta → scrollOffset (зажим 0..max)
- Индикатор «думаю...» (thinking, сброс на ShowText)
- AssistantOverlay: затемнение 72%, по глазам, скролл-аккумулятор
- Тесты: 22 (AssistantGesturesTest) — glasses 91 зелёных
2026-08-23 01:55:24 +03:00
subochev f36d04adc1 Доки: жесты и режимы общения с ассистентом (design) + TASK 2026-08-23 01:08:38 +03:00
subochev 28d0f9b68d Агент: медиа-тулсет (search/info/play) на Jellyfin + скилл media
- MEDIA_TOOLSET + MediaToolExecutor: search (поиск с лимитом), info (детали), play (проверки+запуск)
- JellyfinMediaSource: обёртка JellyfinClient (search/item) с защитой userId
- agentPlayMedia: проверки (очки на связи, видео скачано на очки, звук на телефоне) → watch с позицией
- PhoneActions.watch(startPositionMs), JellyfinItem.ProductionYear
- скилл app-phone/skills/media.md (frontmatter: name/description)
- MediaToolsTest: 22 кейса — всего 142 теста зелёные
2026-08-22 21:42:53 +03:00
subochev c5cbc893ac Агент: механика скиллов и тулсетов (ядро 5 тул, подмена, невидимые enable/disable)
По ТЗ TASK-agent-tools.md:
- Skills/SkillRepository/SkillRegistry: frontmatter-скиллы из files/skills, индекс для промпта, ленивое read_skill
- Toolsets/ToolsetRegistry: активность на сессию в хиппе, таймаут 10 мин (инжект часов), префиксная маршрутизация, идемпотентные enable/disable
- AgentToolkit: сборка промпта (3 состояния), диспач, бесшовная подмена вызова на активацию, валидация аргументов
- Assistant: цикл тулов с невидимыми вызовами (MAX_LLM_CALLS=8, MAX_TOOL_CALLS=3)
- Тесты: 18 новых (AgentToolkitTest 17 + SkillsTest 6 + ToolsetRegistryTest 9) — всего 120, зелёные
2026-08-22 21:13:29 +03:00
subochev 22369670cb ТЗ: агент — скиллы + тулсеты (TASK-agent-tools.md)
Архитектура (договорено 2026-08-22):
- скиллы (знания) ≠ тулсеты (руки)
- ядро 5 тул: время, show_text, read_skill, enable/disable_toolset
- префиксы тул по набору (media_*)
- активность на сессию, в хиппе, таймаут 10 мин
- enable/disable невидимы (не в истории), скилл исчезает при активном наборе
- бесшовная подмена вызова на активацию
- история не вычищается
2026-08-22 20:15:48 +03:00
subochev bbf7a020d4 Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон
- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с
- PlaybackService (foreground, WAKE_LOCK) против морозов HONOR
- Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID)
- Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с
- LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях)
- Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText
- Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
2026-08-22 16:31:04 +03:00
96 changed files with 8842 additions and 636 deletions
+3
View File
@@ -0,0 +1,3 @@
# >>> cortexkit:magic-context
magic-context/
# <<< cortexkit:magic-context
+147
View File
@@ -0,0 +1,147 @@
# Агент «Порфирий» на телефоне: скиллы + тулсеты — архитектура (договорено 2026-08-22)
> Зафиксировано по итогам обсуждения. Это и память, и ТЗ для разработки (opencode).
> Модель сейчас: Qwen3.8-27B-NVFP4 через llm.binom.pw. План: позже перейти на
> Gemma (маленькая, быстрая, on-device) — архитектура должна это пережить без переделки.
---
## 1. Ключевое разделение: СКИЛЛЫ ≠ ТУЛСЕТЫ
- **Скиллы** = знания. «Как пользоваться», рецепты, шаги, питфоллы.
Пассивные, лежат в файлах, читаются по требованию через `read_skill`.
Отвечают на вопрос «КАК?».
- **Тулсеты** = доступные руки. «Какие функции сейчас можно дёргать».
Активные, живут в промпте, меняются динамически. Отвечают на «ЧТО МОЖНО?».
- Пересечение одно: скилл говорит «включи вот этот набор» — дальше механика делает своё.
## 2. Ядро (всегда в промпте, 5 тул, компактно)
1. `get_current_time` — время/дата/пояс, ISO 8601 (уже есть)
2. `show_text` — показать текст на очках (уже есть, ShowText)
3. `read_skill|имя` — вернуть полное содержимое скилла в историю
4. `enable_toolset|имя` — активировать набор тул
5. `disable_toolset|имя` — снять набор тул
Все тулы — **идемпотентные и прощающие**:
- повторный enable — не ошибка, «уже активен, продлеваю таймаут»
- disable без активного — «нет активных наборов», не ошибка
- неизвестное имя набора — «набора X нет, доступны: media, ...»
## 3. Структура промпта (пересобирается на КАЖДЫЙ запрос)
```
1. ЯДРО (всегда): 5 тул с описаниями
2. КАТАЛОГ ТУЛСЕТОВ (всегда, компактно, по строке на набор):
media — управление медиа и воспроизведением (search/info/play)
web — работа в вебе (когда появится)
3. АКТИВНЫЕ ТУЛСЕТЫ (динамически, только активные):
media: активен (+ описания тул media_search, media_info, media_play...)
4. ИНДЕКС СКИЛЛОВ (всегда, имя + триггер, по строке на скилл)
5. Содержимое скиллов — НЕ в промпте, читается через read_skill в историю
```
## 4. Префиксы тул — по имени набора
Все тулы набора имеют префикс: `media_search`, `media_info`, `media_play`.
Префикс = маршрутизация: система видит `media_*` → знает набор, сигнатуры, может
проверить аргументы без реестров-в-сторону.
## 5. Активность тулсета — на СЕССИЮ, в хиппе, по таймауту
- Хранится в памяти (Map): `сессия → (набор → deadline)`
- **Не персистится**: перезапуск приложения = всё сброшено (это правильно:
перезапустил — значит, начал заново; 10-минутные наборы переживать рестарт не должны)
- Таймаут: **10 минут с последнего вызова тулы набора** (enable продлевает)
- Проверка при сборке промпта: `now > deadline` → набора нет, запись подчистить
- Никаких фоновых таймеров — только проверка при сборке промпта
## 6. Три состояния промпта
**Состояние 1 — набор не активен, тема не медиа:**
```
ядро + каталог тулсетов + индекс скиллов
(скилл media в индексе: «для фильмов вызови enable_toolset|media»)
```
**Состояние 2 — нейронка решила активировать (enable_toolset):**
- Вызов enable **НЕ пишется в историю** — система активирует набор в реестре,
пересобирает промпт → следующий запрос уже в состоянии 3
- Это «невидимая системная тула»: её вызовы — часть механики, не диалога
**Состояние 3 — набор активен:**
```
ядро + описания тул media_* + «Активные тулсеты: media» + скилл media УБРАН
```
- Скилл media **исчезает из индекса/промпта**, пока набор активен — иначе
нейронка видит «надо вызвать enable» и соблазняется дёрнуть его повторно.
Искушение удаляется, а не запрещается.
- Секция «Активные тулсеты» нужна: объясняет нейронке, что тулы media_*
легитимны и их можно звать (снимает паралич выбора). 5 токенов.
**Деактивация:** нейронка вызвала disable → выкидываем вызов из истории,
снимаем набор, пересобираем промпт. Симметрично enable.
## 7. Бесшовная подмена вызова на активацию (защита от тупки)
Нейронка вызвала `media_search` при НЕактивном наборе (по привычке/призраку
из истории):
- Система НЕ выполняет вслепую и НЕ ругается
- Подменяет вызов: как будто нейронка вызвала `enable_toolset|media`
- Отвечает: «Тулсет media активирован. Реестр: media_search(query), media_info(id)...
Поиск ещё НЕ выполнялся. Для поиска вызови media_search с аргументом query.»
- Ключевое условие: явно сказать, что вызов ещё не выполнен — иначе нейронка
решит, что тула сработала, и выдумает ответ (галлюцинация)
- Нейронка видит чистую историю: запрос → активация → реестр → search. Никаких швов.
Дополнительная страховка: если аргументы вызова совпадают со схемой из реестра —
можно выполнить сразу, без второго круга.
## 8. История диалога — НЕ вычищается
- Результаты тулов остаются в истории (это память диалога: «а что там с Ларой Крофт?»)
- Призраки тулов из прошлого нейтрализуются НЕ чисткой, а:
а) бесшовной подменой (см. п.7) — модель получит реестр и повторит правильно
б) секцией активных тулсетов — «сейчас активен media, зови его тулы»
- Вычистка истории = амнезия («что ты мне показывал про Лару Крофт?» — не знаю) — НЕ ДЕЛАЕМ
## 9. Скиллы — файловая структура
- Папка `skills/` (на телефоне, внутренние файлы приложения)
- Формат: `name + описание-триггер в одну строку + тело (шаги, команды, питфоллы)`
- `SkillRepository` — объединяет источники (встроенные assets + пользовательские)
- `SkillRegistry` — индекс (имя + триггер), идёт в промпт
- Полное содержимое — только через `read_skill|имя` (лениво)
Скилл media (пример содержания):
```
Триггер: поиск фильмов, детали, запуск/управление просмотром.
Вызови enable_toolset|media, дождись ответа — в нём реестр тул:
media_search — поиск по названию (аргумент query)
media_info — детали фильма (аргумент id)
media_play — запуск/управление (аргументы...)
```
## 10. Требования к тулам набора (для маленькой модели)
- Описания ЗЛЫЕ И КОРОТКИЕ: имя + одна строка + аргументы в одну строку
- Активный набор ≈ 400 токенов, не гора
- Валидация аргументов на исполнителе: невалидные → «аргументы неверны, вот схема»
## 11. Тестирование
- **Чистая логика — JVM-юнит-тесты** (без Android):
сборка промпта (3 состояния), подмена вызова, таймауты, идемпотентность,
каталог/реестр, префиксная маршрутизация
- **На железе** (142, adb): полный цикл — вопрос → read_skill → enable → search → ответ;
переключение сессий (активность на сессию); таймаут 10 мин (ускорить для теста);
подмена вызова при неактивном наборе; двойной enable/disable
## 12. Известные решения (не пересматривать)
- Протокол вызова: `TOOL_CALL: имя|аргументы` (строка, не JSON — маленькая модель)
- Активность тулсета: на сессию, в хиппе, 10 мин от последнего вызова
- enable/disable — невидимые тулы (вызовы не в истории)
- Скилл исчезает из промпта при активном наборе
- История не вычищается
- Подмена вызова на активацию — с явной пометкой «вызов ещё не выполнен»
+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-ом (не агента).
+51
View File
@@ -0,0 +1,51 @@
# TASK-full-buffer-stt.md — полный текст по клику: распознавание всего буфера (не VAD-сегментов)
## Симптом (24.08, подтверждено дампами)
Пользователь: TripleClick → говорит → Click → «думаю...» → сразу возврат в «слушаю». Полный текст пуст (`stt ВЕСЬ ТЕКСТ: ` пусто в логах телефона).
## Диагноз (по дампам голоса пользователя)
1. Микрофон RayNeo X2 пишет РВАНО: пачки 50–100мс с провалами до нуля (в дампе 213110, 16.7с — 66% тишины). Это железо/драйвер очков, не транспорт (очки→телефон совпадают 98%).
2. Silero VAD (тот же `silero_vad.onnx` с телефона, прогнан на хосте) на усиленных дампах нарезает речь на 10+ сегментов по 0.3–0.4с (213110: 12 сегментов).
3. Whisper (faster-whisper-base на хосте) на ПОЛНОМ дампе распознаёт «Ого! | А что ты делаешь?» (212441, 5.9с) и «...грека...» (213110) — НЕ пусто. На отдельных VAD-сегментах 0.3–0.4с whisper возвращает пусто/мусор.
4. Вывод: **кликовый режим не должен зависеть от VAD-сегментации.** Начало/конец фразы задаёт пользователь кликами; полный текст = распознавание ВСЕГО накопленного PCM одним вызовом whisper. VAD остаётся только для промежуточных фраз на очки (не трогать).
## Задача
`app-phone` `SttStreamer.kt`:
1. **Буфер всей сессии**: в `accept(pcm)` накапливать принятые сэмплы (FloatArray) в поле `sessionPcm` (сбрасывается в `resetLocked()`). Не терять при VAD-обработке (VAD-путь остаётся как есть, параллельно).
2. **`finish()`**: распознать полный буфер одним вызовом whisper:
- `sessionPcm` целиком, если ≤ 30с (16000*30 сэмплов);
- если длиннее — последние 30с (хвост: `sessionPcm.takeLast(16000*30)`), чтобы команда пользователя (сказанная перед кликом) точно попала;
- результат: `stt.recognize(chunk)` — один вызов, текст в `full` (перезаписать, не конкатенировать с VAD-фразами).
- `finish()` по-прежнему ждёт `lastFuture` (VAD-распознавания в executor), затем выполняет полный прогон.
3. **`resetLocked()`**: очищать `sessionPcm`.
4. Логи: при распознавании полного буфера `log("stt", "полный буфер: ${N} сэмплов (${dur}с) → '${text}'")`.
НЕ трогать: охраняемые 4 файла (`HostToGlasses.kt`, `GlassesServer.kt`, `HostConnection.kt`, `MainActivity.kt`), протокол `lib-core`, `app-glasses`, `WhisperStt`, `StreamingVad`/`SherpaStreamingVad`, VAD-путь и `onPhrase` (промежуточные фразы).
## Точки правки
- `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt` — буфер + finish() + resetLocked().
- Тесты: новый `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt` (JVM, фейковый `PhraseRecognizer`, `FakeVad` как в существующих тестах — см. `stopSttFullTextSendsSttDone` фикстуру).
## Тесты (JVM)
1. `accept` 3 чанка по 3200Б (9600Б = 0.3с) → `finish()` → фейковый recognizer получил ОДИН вызов с полным буфером (9600 байт → 4800 сэмплов), `full` = текст фейка.
2. Длинный буфер > 30с: `accept` 40с → `finish()` → recognizer получил ровно 16000*30 сэмплов (хвост), текст не пуст.
3. `reset()` → `finish()` → recognizer НЕ вызывался, `full` пуст.
4. VAD-путь не сломан: `FakeVad` с одним сегментом → `onPhrase` по-прежнему вызывается (промежуточная фраза бродкастится), но `full` после `finish()` = результат полного буфера.
## Проверка
1. `./gradlew :app-phone:testDebugUnitTest` — зелёные (было 158 + новые).
2. Полная: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`.
3. Коммит: `stt(phone): полный текст по клику — whisper на всём буфере, VAD только для промежуточных фраз`. Push НЕ делать.
## Приёмка на железе (Hermes + пользователь)
- Установить оба APK, force-stop, `am start`.
- Пользователь: TripleClick → фраза («поставь спокойную музыку») → Click.
- Ожидание: `stt ВЕСЬ ТЕКСТ: поставь спокойную музыку` (не пусто) → LLM → ответ на очки.
+47
View File
@@ -0,0 +1,47 @@
# Задача: жесты и режимы общения с ассистентом на очках (design в docs/gestures-and-modes.md)
Прочитай `docs/gestures-and-modes.md` — там согласованная с пользователем матрица жестов и все решения. Реализуй в `app-glasses`. Механику тулсетов/ассистента (app-phone) НЕ трогать.
## Что сделать
### 1. Переразметка жестов по матрице (MainActivity.kt, collectTempleActions)
| Состояние | Click | DoubleClick | TripleClick | Свайпы/SlideContinuous |
|---|---|---|---|---|
| Кино (listening=false, assistantText пуст) | TOGGLE (как сейчас) | игнор | вызов ассистента (как сейчас) | ±30с (как сейчас) |
| Прослушивание (listening=true) | отправить (как сейчас) | отмена, остаёмся в диалоге (как сейчас) | игнор | игнор |
| Ответ виден (listening=false, assistantText непуст) | **новая фраза**: стереть assistantText, открыть микрофон, listening=true | **выход из режима**: закрыть оверлей, стереть assistantText, фильм на паузе (НЕ трогать play/pause — пауза уже стоит с момента входа) | игнор | **скролл ответа** (плавный SlideContinuous, запасной — дискретные свайпы) |
Важно: состояние «ответ виден» определяется `!listening && assistantText.isNotBlank()`.
### 2. Скролл ответа (плавный)
- SlideContinuous: накапливать `delta` (float) → смещать scrollState текста ответа. Направление: SlideForward = вниз по тексту (к концу), SlideBackward = вверх (к началу) — уточнить знак дельты на железе.
- Ограничить скролл границами текста (0..maxScroll).
- Запасной вариант: SlideForward/SlideBackward — скролл на страницу (например, ±200px или высоту видимой области), если SlideContinuous окажется шумным.
- Текст ответа в оверлее должен быть в scrollable-контейнере (Column + verticalScroll(rememberScrollState()) или LazyColumn — на твой выбор, но скролл обязателен).
### 3. Индикатор «думаю...»
- Состояние thinking: true с момента отправки фразы (Click при прослушивании / SttPhrase ушёл) до прихода ShowText.
- В оверлее: если thinking и assistantText пуст — показывать «думаю...» (вместо «слушаю...» после того как фраза отправлена).
- Как определить: пока listening=true — «слушаю...»/текст фразы; когда listening стал false, а ShowText ещё не пришёл — «думаю...».
### 4. Выход из режима
DoubleClick при «ответ виден»: закрыть оверлей (listening=false, sttText="", assistantText=""), НЕ трогать movieController (фильм остаётся на паузе — он уже на паузе с момента входа в режим).
## Ограничения
- Код только Kotlin
- НЕ трогать app-phone (ассистент, тулсеты, скиллы)
- НЕ трогать MovieController/playback-логику
- Тесты: юнит-тесты на чистую логику разметки жестов (новое состояние «ответ виден», переходы, скролл-дельта → смещение) — в духе GestureCommandsTest. Если логика в MainActivity (не чистая) — вынеси чистую часть (решение по жесту из состояния) в отдельный файл/функцию для тестируемости.
- Сборка: `./gradlew :app-glasses:assembleDebug` — зелёная; `./gradlew :app-glasses:testDebugUnitTest` — зелёный (старые + новые)
- НЕ коммитить
## Проверка
1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные
2. `./gradlew :app-glasses:assembleDebug` — собирается
3. Опиши в ответе: какие файлы создал/изменил, как определил состояние «ответ виден», как работает скролл (SlideContinuous или запасной), что осталось проверить на железе.
+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.
- В конце — краткий отчёт: файлы, что изменено, результат сборки/тестов.
+75
View File
@@ -0,0 +1,75 @@
# Задача: интерфейс LLM + две реализации (Qwen удалённо / Gemma E2B локально) + переключатель
Дизайн — TASK-local-llm.md. Заметка с разбором litertlm (скопирована в репо, читай её): `docs/litertlm-openassistant-notes.md`. Полная архитектура агента (тулсеты/скиллы) — TASK-agent-tools.md. Реализуй в `app-phone`.
## Контекст
- Сейчас `LlmClient` — класс с прямым OkHttp к `https://llm.binom.pw/v1/chat/completions` (Qwen3.8-27B-NVFP4). Файл: `app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt` (НЕ app-phone/agent/ — путь с src/main/kotlin!). Вызывается из `PhoneApp.ensureAssistant()` (app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt) — создаётся один раз и передаётся в `Assistant(chat = { history, system -> llm.chat(history, system) })`.
- Нужно: **интерфейс** + **две реализации** + **ручной глобальный переключатель** в UI телефона. В будущем всё должно работать оффлайн.
- Модель для локальной: **Gemma 4 E2B (2B)**, формат `.litertlm` (LiteRT LM, Google). Разбор — в `docs/litertlm-openassistant-notes.md` (скопирован в репо — читай его, не /root/notes/).
## Что сделать
### 1. Интерфейс LlmClient
Превратить `LlmClient` в интерфейс (или добавить интерфейс-обёртку):
```kotlin
interface LlmClient {
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
}
```
- Существующий класс → `RemoteLlmClient` (реализация интерфейса, код не менять по сути — только implements).
- Все использования (PhoneApp.ensureAssistant) — через интерфейс.
### 2. Локальная реализация — `LocalLlmClient` (Gemma 4 E2B, LiteRT LM)
По заметке `/root/notes/openassistant-lite-llm.md` (секция «Две нейронки + токенайзер», «Как повторить», «Скорость»):
- **Зависимость**: `com.google.ai.edge.litertlm:litertlm-android:0.14.0` в app-phone/build.gradle.kts (репозиторий — google(), у нас в settings должен быть; проверить).
**ВАЖНО**: litertlm 0.14.0 требует **kotlinx-coroutines 1.11.0** (иначе NoSuchMethodError close$default на SendChannel — подтверждено автором Modern-Apps, см. его build.gradle.kts:47-48). Поднять версию kotlinx-coroutines в gradle/libs.versions.toml с 1.10.2 до 1.11.0 — проверить, что ничего не ломается (тесты после этого обязаны быть зелёными).
- **Модель**: файл `gemma-4-E2B-it.litertlm` (SHA256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`), ~0.8 ГБ. Хранить в `getExternalFilesDir(null)` (как у автора). **НЕ класть в APK** (большой) — загружается отдельно.
- **Загрузка**: если файла нет — скачать с зеркала (автор: `https://data.vayunmathur.com/models/`, но у нас доступнее HF: `https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/<файл>`), SHA256-проверка. Сделай через OkHttp (уже есть в проекте) + поток в файл, с прогрессом (лог).
- **Запуск** (из заметки):
- `Engine(EngineConfig(modelPath, backend = GPU(), visionBackend = GPU(), audioBackend = CPU(), cacheDir))`
- `ExperimentalFlags.enableSpeculativeDecoding = true` **ПЕРЕД** `engine.initialize()` (спекуляция у Gemma 4 встроена в архитектуру — MTP, флаг включает рантайм-путь)
- `ConversationConfig(systemInstruction, initialMessages, tools?, automaticToolCalling?)` + `sendMessageAsync` стриминг
- **Chat**: перевести `List<ChatMessage>` + system в формат litertlm (initialMessages — история, systemInstruction — system). Вернуть накопленный текст ответа. Стриминг можно агрегировать (не обязательно гнать по частям наружу — контракт `chat(): String`).
- **Обработка ошибок**: модель не загружена → понятное сообщение («локальная модель не скачана, скачайте в настройках»), инициализация упала → текст ошибки. НЕ падать.
- **Threading**: Engine litertlm требует аккуратности с потоками — смотри пример автора (InferenceService.kt). Скорее всего вызовы идут из корутины — оберни в withContext(Dispatchers.IO) и соблюдай требования движка.
- **Время жизни**: Engine создаётся один раз (как ассистент), переиспользуется; инициализация — лениво при первом вызове (модель может весить ~1-2 ГБ в RAM).
### 3. Переключатель (ручной, глобальный)
- **Состояние**: `LlmmodelChoice` = `REMOTE` | `LOCAL` (или enum с именами). Хранить в SharedPreferences (переживает рестарт). Global: влияет на весь ассистент, не на сессию.
- **Фабрика**: `PhoneApp.ensureAssistant()` выбирает реализацию по сохранённому выбору: REMOTE → `RemoteLlmClient`, LOCAL → `LocalLlmClient`. Переключение — пересоздать ассистента (или хотя бы llm-ссылку) при изменении выбора.
- **UI**: на экране «Информация об очках» (GlassesInfoScreen, там уже есть InfoRow) — или лучше отдельный блок в GlassesScreen — добавить переключатель «Модель ассистента: Qwen (сервер) / Gemma (локально)». Компонент: два варианта (радио-кнопки или сегментированная кнопка), плюс статус: для LOCAL — «модель скачана/не скачана», кнопка «Скачать модель» (с прогрессом), для REMOTE — «сервер».
- **Индикатор активной модели** — в чате (ChatScreen) мелкой подписью «модель: qwen/gemma» (не обязательно, но желательно — пользователь должен видеть, кто отвечает).
### 4. Проверка/тесты
- Юнит-тесты (JVM, без Android — что можно):
- Выбор реализации по настройке (фабрика/логика выбора — чистая часть).
- Формирование промпта для litertlm (маппинг ChatMessage → initialMessages) — если вынести в чистую функцию.
- SHA256-проверка файла (чистая функция).
- Сборка: `./gradlew :app-phone:testDebugUnitTest` и `:app-phone:assembleDebug` — зелёные (все старые 142 теста остаются зелёными).
## Ограничения
- Код только Kotlin
- НЕ трогать механики тулсетов/скиллов (AgentToolkit/Skills/Toolsets), Assistant.kt — менять только точку создания LlmClient
- НЕ ломать существующие экраны
- НЕ коммитить
## Проверка на железе (после твоей части)
1. Сборка + установка на телефон (142, adb).
2. Загрузка модели (нужен доступ к HF — проверь, скачай на машину, залей через adb, если HF с телефона недоступен).
3. Переключение REMOTE ↔ LOCAL, общение с ассистентом на каждой.
4. Оффлайн-тест: `svc wifi disable` → локальная модель отвечает, удалённая — понятная ошибка.
5. Замер скорости локальной (ток/с из логов).
## Ответ
Опиши: какие файлы создал/изменил, как устроен интерфейс, как инициализируется Engine, где переключатель, что осталось проверить на железе (особенно: работает ли litertlm на нашем устройстве, скачивается ли модель).
+46
View File
@@ -0,0 +1,46 @@
# Локальная модель на телефоне (Gemma 4 E2B) — ПЛАН (в очереди, НЕ реализовано)
> Статус: ЗАФИКСИРОВАНО 2026-08-23 ночью. Реализация НЕ начата — ждёт отмашки пользователя.
> Пользователь: «как закончишь с тем, что мы уже напланировали, нужно будет написать интерфейс и две реализации: одна ведёт на наш qwen 3.8, а вторая — вот эта вот модель. В будущем в идеале у нас всё должно мочь работать в офф-лайн режиме».
## Источник (что за модель и как она быстрая)
- Репозиторий: **vayun-mathur/Modern-Apps** → `openassistant` (клон: `~/WORK/modern-apps/`)
- Модель: **Gemma 4 E2B (2B)** в формате `.litertlm` (LiteRT LM, Google; бывш. MediaPipe LLM Inference / AI Edge)
- Файл: `gemma-4-E2B-it.litertlm` (SHA256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`), ~0.8 ГБ, контекст 32K
- Источник модели: `huggingface.co/litert-community/gemma-4-E2B-it-litert-lm`
- Скорость на Honor Magic V3 (Snapdragon 8 Gen 3, Adreno 750, 12 ГБ): **20-35 ток/с** (MindStudio, спекуляция вкл), официально на S26 Ultra до 66-92 ток/с
- Секрет скорости: GPU-бэкенд (OpenCL) для text + **встроенный MTP-спекулятивный декодинг** (Multi-Token Prediction у Gemma 4 — черновик-головы в чекпоинте), флаг `ExperimentalFlags.enableSpeculativeDecoding = true`
- Замена модели проста: готовые `.litertlm` на HF (Qwen3 0.6B/1.7B/8B, Qwen2.5, Phi-4-mini, Gemma3-1B/4B, FunctionGemma-270M и др.) — поменять URL+SHA256
- Полный разбор: `/root/notes/openassistant-lite-llm.md` (2026-08-10)
## Задача (когда будет отмашка)
**Интерфейс LLM + две реализации** в app-phone (агент «Порфирий»):
1. **Абстракция**: `interface LlmClient` (или sealed): метод `chat(history, system): String` — текущий контракт (см. `LlmClient.kt`, прямой OkHttp к `https://llm.binom.pw/v1/chat/completions`).
2. **Реализация 1 — удалённая (уже есть)**: Qwen3.8-27B-NVFP4 через llm.binom.pw (прямой OkHttp, TOOL-результаты → user-сообщения). Переоформить под интерфейс.
3. **Реализация 2 — локальная**: Gemma 4 E2B через LiteRT LM:
- Библиотека: `com.google.ai.edge.litertlm:litertlm-android:0.14.0`
- `Engine(EngineConfig(modelPath, backend=GPU(), visionBackend=GPU(), audioBackend=CPU(), cacheDir))` + `engine.initialize()`
- `ExperimentalFlags.enableSpeculativeDecoding = true` (ПЕРЕД initialize)
- Модель на диске: `getExternalFilesDir(null)/gemma4-2b.litertlm` (скачать с зеркала, SHA256 проверка — как у автора, без HF-фолбэка)
- `ConversationConfig(systemInstruction, initialMessages, tools?, automaticToolCalling?)` + `sendMessageAsync` стриминг
- Формат .litertlm: внутри квантованная модель, эмбеддинги (memory-mapped), токенайзер, KV-cache менеджмент, function calling
4. **Оффлайн-режим**: переключатель/автовыбор — сеть есть → Qwen3.8, нет → Gemma E2B (или наоборот, решить с пользователем). Идеал: «всё может работать оффлайн».
## Риски/нюансы (из заметки)
- Спекуляция вшита в сборку модели — у Gemma 4 E2B есть, у других не факт; tools/automaticToolCalling — у Gemma/Qwen/Phi есть, не у всех
- Русский текст у Qwen обычно лучше — для локальной можно рассмотреть Qwen3-1.7B (компромисс скорость/русский)
- 8 Gen 3 термальный капкан: непрерывная генерация → троттлинг до 2 раз; паузы между запросами обязательны
- Батарея: ~4-4.5 часа непрерывной генерации (20-25%/час); сценарий ассистента 5-10 мин/час → 1.5-4%/час — незаметно
- Память 12 ГБ хватает: модель на GPU ~676-800 МБ RSS, эмбеддинги 1.12 ГБ memory-mapped
- Android-интеграция sherpa-onnx (STT) уже работает — только LLM-часть заменить
## Проверка
- Юнит-тесты: интерфейс, выбор реализации, конфиг
- На железе: полный цикл (голос → STT → локальная LLM → ответ) при выключенном интернете (airplane mode / svc wifi disable)
- Замер скорости: ток/с, TTFT на живом Honor Magic V3
- Сравнение ответов Qwen3.8 vs Gemma E2B на одинаковых запросах
+65
View File
@@ -0,0 +1,65 @@
# Задача: медиа-тулы для тулсета media (агент «Порфирий»)
Механика тулсетов/скиллов УЖЕ готова (AgentToolkit, Skills, Toolsets — см. TASK-agent-tools.md, закоммичено c5cbc89). Твоя задача — **реализовать сам тулсет media с исполнителем** на базе Jellyfin. Чистую механику НЕ трогать.
## Контекст (что уже есть)
- `AgentToolkit` (app-phone/.../agent/AgentToolkit.kt): ядро 5 тул + диспач + подмена. Регистрация тулсета: `ToolsetRegistry.register(Toolset(...), executor)`.
- `Toolset`/`ToolSignature`/`ToolsetExecutor` (Toolsets.kt): сигнатуры с префиксом `media_`, аргументы строками (`query`, `id`, `seek_ms?`).
- `PhoneApp.kt` уже создаёт `AgentToolkit(skills=..., toolsets=ToolsetRegistry(), showText=...)` — тулсеты пустые, тулы не зарегистрированы.
- `JellyfinClient` (lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt):
- `suspend fun search(userId: String, query: String): List<JellyfinItem>`
- `suspend fun item(userId: String, itemId: String): JellyfinItem?`
- `suspend fun allItems(userId: String, types: List<String>): List<JellyfinItem>`
- `fun posterUrl(itemId: String, maxWidth: Int): String`
- `JellyfinItem` (JellyfinModels.kt): Id, Name, Type, Overview, ImageTags, SeriesName, SeasonId, IndexNumber, ParentIndexNumber, RunTimeTicks.
- В PhoneApp: `jellyfin: JellyfinClient`, `chatDao`, `server.hub` (broadcast), `actions` (воспроизведение), `state`.
## Что сделать
### 1. Тулсет media (зарегистрировать в PhoneApp)
Тулы (имена БЕЗ префикса — префикс `media_` добавляется механизмом):
- `search` — поиск фильмов/сериалов по названию. Аргументы: `query` (обязательный), `limit?` (опциональный, дефолт 5).
- Возврат: список строк «id: Название (Год) — Тип» (до limit), либо «ничего не найдено по запросу ...».
- `info` — детали элемента по id. Аргументы: `id` (обязательный).
- Возврат: Название, Тип, Год, Обзор (Overview, обрезать до ~300 символов), длительность (RunTimeTicks/10_000_000 сек), есть ли постер.
- `play` — запуск воспроизведения на очках. Аргументы: `id` (обязательный), `seek_ms?` (опциональный).
- Проверки: очки подключены (hub.connected > 0), видео скачано на очки (локальный путь file://<glassesMediaDir>/<id>/video.mkv существует на очках — через hub/каталог), звук готов на телефоне (audioReady).
- Воспроизведение — через существующий механизм DetailsScreen (actions.watch(item.Id, audioIndex, localVideoPath, phoneAudioReady)) — вынеси/переиспользуй логику, не дублируй криво.
- Возврат: «запущено: <Название>» или понятная ошибка с причиной («очки не подключены», «видео не скачано на очки — скачай вручную», «звук не готов»).
### 2. Юзер-ID для Jellyfin
Jellyfin требует userId. Посмотри, как DetailsScreen/CatalogScreen получают userId (вероятно `app.jellyfin.users()` первый или конфиг). Используй тот же механизм, закешируй.
### 3. Скилл media (файл skills/media.md)
Формат (как в Skills.kt):
```
---
name: media
description: поиск фильмов, детали, запуск/управление просмотром.
---
Текст скилла: объясни модели, что для работы с медиа вызвать enable_toolset|media, и опиши тулы search/info/play с аргументами.
```
### 4. Тесты (JVM, без Android — по возможности)
Механику тестировать НЕ надо (уже покрыта). Но исполнитель media_search можно покрыть, если JellyfinClient абстрагируется (интерфейс/fake). Если JellyfinClient — конкретный класс с Ktor, сделай минимальный интерфейс `MediaSource` (search/item) и обёртку — тогда executor тестируется на фейке. Не усложняй сверх меры: если это требует большого рефакторинга, опиши, что осталось непокрытым.
## Ограничения
- Код только Kotlin
- НЕ трогать AgentToolkit/Skills/Toolsets (механика) — только регистрация тулсета и его исполнитель
- НЕ ломать существующие экраны (DetailsScreen/CatalogScreen)
- Все новые файлы — в app-phone (агент живёт на телефоне)
- Сборка: `./gradlew :app-phone:assembleDebug` — зелёная; `./gradlew :app-phone:testDebugUnitTest` — зелёный (все старые тесты остаются зелёными)
- НЕ коммитить
## Проверка
1. `./gradlew :app-phone:testDebugUnitTest` — зелёные (старые + новые)
2. `./gradlew :app-phone:assembleDebug` — собирается
3. Опиши в ответе: какие файлы создал/изменил, как зарегистрирован тулсет, как получается userId, что осталось непокрыто тестами.
+82
View File
@@ -0,0 +1,82 @@
# TASK-mic-gain.md — усиление микрофона очков (фикс «пустой фразы»)
## Симптом (пользователь, 24.08 вечер)
Очки: TripleClick → «слушаю» → пользователь говорит → Click («отправить фразу») → очки показывают «думаю...» и СРАЗУ возвращаются в «слушаю». Ответа нет. Впечатление: «процесс говорения сломан».
## Диагноз (подтверждён дампами и логами 24.08)
Цепь: очки `SttMicStream` (AudioRecord MIC 16 кГц s16 mono) → BT SPP → телефон `SttStreamer` (Silero VAD → sherpa whisper-small) → `StopStt(cancel=false)` → `GlassesServer` → `full.isBlank()` → `SttCancel(REASON_USER)` → очки возвращаются в «слушаю».
Факты:
1. **Жесты/протокол/цикл здоровы** — симуляция `DEBUG_GESTURE` (triple_click → click) воспроизводит симптом, очки логируют `Click → отправить фразу`, `stt отменено — возврат в «слушаю» (SttCancel)`. Это штатное поведение для ПУСТОЙ фразы.
2. **Микрофон RayNeo X2 пишет сигнал пиком 1–9% шкалы** (пики 181–2966 из 32768; RMS 9–184). Дампы `stt_dump_1930*.wav` (19:30, голос пользователя): после нормализации faster-whisper-base распознаёт «тихо тихо», «спокойная музыка» — РЕЧЬ есть, но амплитуда ничтожна.
3. **Транспорт очки→телефон чист** (analyze.py: порядок окон 98%, лаг 0, объём совпадает) — телефон получает тот же тихий PCM.
4. **STT-пайплайн здоров** — на эмуляторе (WAV нормального уровня) распознавание работает; на амплитудах ~0.005–0.09 Silero VAD/whisper-small не видят речи → `full` пуст → пустой клик → возврат в «слушаю».
5. Вчерашний фикс кадров/байтов (`c940a7e`+`fc84ba1`+`40edd88`) правильный и НЕ трогается — он убрал «через один», но уровень микрофона не поднял.
**Корень: на пути аудио очки→телефон нет усиления. Микрофон тихий, VAD/Whisper не видят речь.**
## Задача
Добавить **фиксированное цифровое усиление** в `app-glasses` `SttMicStream.kt`, в живой ветке микрофона: чанк после `micChunkBytes(buf, frames)` усилить в `MIC_GAIN` раз (с clamp), и уже усиленный чанк отправлять `onChunk(...)` и писать в дамп.
- Точка правки: `app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt`
- Тесты: `app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt` (существующий файл, добавить тесты)
- НЕ трогать: `HostToGlasses.kt`, `GlassesServer.kt`, `HostConnection.kt`, `MainActivity.kt` (охраняемые 4 файла фикса `7557d72`), телефонную сторону (`app-phone`), протокол (`lib-core`), `WavWriter.kt`, существующую функцию `micChunkBytes` и её тесты.
- `SttMicStream.kt` писать можно (не в охране).
## Реализация
В `SttMicStream.kt`, рядом с `micChunkBytes`:
```kotlin
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
internal const val MIC_GAIN = 10f
/**
* Цифровое усиление чанка s16le mono с насыщением.
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
*/
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray
```
Разбор s16le: младший байт + (старший << 8), знак как в `s16leToFloat` (app-phone) — `if (s >= 0x8000) s -= 0x10000`.
В цикле записи (живая ветка, после фикса `40edd88`):
```kotlin
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2)
if (frames > 0) {
val raw = micChunkBytes(buf, frames)
val chunk = gainChunk(raw) // ← усиление здесь
if (dumpWriter != null) runCatching { dumpWriter.write(chunk) }.onFailure { ... }
onChunk(chunk)
}
```
**ВАЖНО: дамп (`WavWriter`) пишет УСИЛЕННЫЙ чанк** (то же, что уходит на телефон) — чтобы дампы очков отражали то, что реально получает STT. (Решение принято: диагностика по дампам очков = тому, что видит телефон; сырой микрофон больше не пишем.)
Обоснование gain = 10: лучший утренний пик речи 2966 (9.05%) → 29660 (90.5%); тихая фраза 483 (1.5%) → 4830 (14.7%) — уже видно VAD. При крике возможен клиппинг — приемлемо (лучше клиппинг, чем пустота). Если на железе VAD захочет ложно срабатывать — константу подстроим.
## Тесты (добавить в MicChunkTest.kt)
1. `gainChunk` тихого чанка (сэмплы ±1000): пик результата ≥ 9000, размер = 3200 байт.
2. `gainChunk` громкого чанка (сэмплы ±30000): ни один сэмпл не выходит за ±32767 (clamp работает).
3. `gainChunk` нулевого чанка: байты без изменений.
4. `gainChunk` с gain = 1f: байты побайтово идентичны входу (для gain=1 не аллоцировать заново — вернуть `bytes`).
5. Сохранение порядка: `gainChunk` не перемешивает сэмплы (знак/порядок сохраняется — проверка первых 2 сэмплов).
## Проверка
1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные (было 95, станет 95 + новые).
2. Полная сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`.
3. Коммит отдельный: `stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза)`. **push НЕ делать** — приёмка на железе.
## Приёмка на железе (делает Hermes + пользователь, НЕ агент)
- Установить оба APK (очки + телефон) с этого коммита, force-stop, `am start`.
- Маркеры дампов `stt_dump.marker` уже лежат на обоих устройствах — дампы пишутся.
- Пользователь: TripleClick → говорит «поставь спокойную музыку» → Click.
- Hermes снимает: дампы очков (пик речи должен стать ~50–90%), `stt_dump_phone.raw` (телефон получил усиленный PCM), logcat: `SttDone` с непустым текстом → LLM-запрос → ответ на очки.
+55
View File
@@ -0,0 +1,55 @@
# Задача: реализовать механику скиллов и тулсетов агента «Порфирий»
Прочитай ТЗ: TASK-agent-tools.md (в корне репо). Реализуй ТОЛЬКО механику, БЕЗ привязки к медиа-данным (медиа-тулы будут позже, отдельно).
## Объём работ
### 1. Механика скиллов
- Абстракция `Skill`: name, description (триггер, одна строка), body (текст)
- `SkillRepository`: читает скиллы из папки `files/skills/` (YAML frontmatter: `---\nname: ...\ndescription: ...\n---\n` + markdown body)
- `SkillRegistry`: индекс (имя + триггер) для промпта; `readSkill(name)` возвращает полное содержимое
- Скиллов реальных пока НЕТ (механика работает на пустом каталоге или тестовых скиллах в юнит-тестах)
### 2. Механика тулсетов
- `Toolset`: name, описание набора (для каталога), список тул (имя с префиксом набора + описание + сигнатура аргументов)
- `ToolsetRegistry`: активность НА СЕССИЮ (Map сессия→(набор→deadline)), хипп, таймаут 10 минут с последнего вызова тулы набора, проверка при сборке промпта (никаких фоновых таймеров)
- `enable_toolset|имя`: идемпотентно (повторный = продлить), неактивный вызов НЕ пишется в историю (системная тула-невидимка)
- `disable_toolset|имя`: снять, идемпотентно («нет активных наборов» — не ошибка)
- Неизвестное имя набора — «набора X нет, доступны: ...»
### 3. Сборщик промпта (три состояния)
- Состояние 1 (набор не активен): ядро + каталог тулсетов + индекс скиллов
- Состояние 2 (активация): enable вызывает пересборку, вызов в историю НЕ пишется
- Состояние 3 (активен): ядро + описания тул набора + «Активные тулсеты: имя» + скилл, связанный с набором, УБРАН из промпта
- Промпт пересобирается на каждый запрос
### 4. Бесшовная подмена вызова
- Вызов тулы набора при НЕактивном наборе (например media_search): НЕ выполнять, подменить как будто вызван enable_toolset|media, ответить «Тулсет media активирован. Реестр: ... Поиск ещё НЕ выполнялся. Для поиска вызови media_search с аргументом query.»
- Если аргументы вызова совпадают со схемой из реестра — можно выполнить сразу
### 5. Обработчик вызовов
- Префиксная маршрутизация: имя тулы с префиксом набора (media_*) → набор
- Ядро-тулы: get_current_time (есть), show_text (есть ShowText), read_skill, enable_toolset, disable_toolset
- Все тулы идемпотентные, прощающие, с понятными ошибками
- Валидация аргументов по реестру
### 6. Юнит-тесты (JVM, без Android) — ОБЯЗАТЕЛЬНО
Покрыть механику полностью:
- сборка промпта: 3 состояния
- подмена вызова при неактивном наборе (включая «поиск ещё не выполнен»)
- идемпотентность enable/disable
- таймаут (инжектируемые часы — не ждать реальные 10 минут)
- активность на сессию (переключение сессий)
- префиксная маршрутизация, валидация аргументов
- read_skill: чтение из files/skills, парсинг frontmatter
## Ограничения
- Код только Kotlin (как в проекте)
- НЕ трогать существующую логику ассистента без необходимости — встраиваться аккуратно
- НЕ делать реальные медиа-тулы (search по каталогу и т.п.) — это отдельный этап
- Юнит-тесты должны быть зелёными: ./gradlew :app-phone:testDebugUnitTest
- Не коммитить без подтверждения
## Проверка
1. ./gradlew :app-phone:testDebugUnitTest — зелёные
2. ./gradlew :app-phone:assembleDebug — собирается
+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 (скролл уже починен).
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
+103
View File
@@ -0,0 +1,103 @@
# Абстракция транспорта связи очки↔телефон (view-mate)
## Проблема
Сейчас связь очки↔телефон жёстко завязана на **WiFi + WebSocket**:
- очки: `GlassesWsClient` (Ktor WS-клиент) внутри `HostConnection`, discovery = mDNS (`MdnsClient`) + скан /24 (`PhoneScan`)
- телефон: `GlassesServer` (Ktor CIO embeddedServer, WS на 0.0.0.0:8080/ws/glasses) + `GlassesHub` (сессии = `DefaultWebSocketServerSession`)
WiFi на реальном железе нестабилен (пользователь: «wifi в рамках view-mate — неверное решение, блютус на много стабильнее»). Нужно сделать транспорт **абстрактным**, чтобы реализацию можно было подставить: **WifiTransport** (текущий WS) и **BluetoothTransport** (новый, RFCOMM/BluetoothSocket).
## Цель
Выделить интерфейс транспорта, одинаковый для обеих сторон, и две реализации:
1. **WifiTransport** — обёртка над существующим кодом (без изменения логики)
2. **BluetoothTransport** — новая реализация на Android BluetoothSocket (RFCOMM)
Протокол сообщений (HostToGlasses/GlassesToHost, kotlinx.serialization JSON) — **НЕ трогать** (пересылаются как JSON-строки, что WS, что BT-сокет).
## Текущая структура (не менять без нужды, только рефакторинг)
**lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/**
- `net/GlassesWsClient.kt` — WS-клиент очков: `suspend connect(onHostMessage, onConnected, onDisconnected)` (бесконечный цикл приёма с реконнектом 3с), `suspend send(msg: GlassesToHost)`, `close()`
- `phone/GlassesSender.kt` — интерфейс телефона: `interface GlassesSender { suspend fun send(msg: HostToGlasses) }`
- `protocol/` — HostToGlasses, GlassesToHost, Json (protocolJson), MediaCommand и т.д.
**app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/**
- `HostConnection.kt` — обвязка: discovery (MdnsClient → PhoneScan), connectLoop, реконнект, Hello, статус-цикл 5с (buildStatus + PlaybackPosition), обработка HostToGlasses (handleHostMessage), reconnectResume-автоматика
- `MdnsClient.kt`, `PhoneScan.kt` — WiFi-discovery
**app-phone/src/main/kotlin/pw/binom/viewmate/phone/**
- `GlassesServer.kt` — `GlassesHub` (ConcurrentHashMap<DefaultWebSocketServerSession, Unit>, connected/gesturesReceived/batteryPercent, broadcast, обработка входящих: Hello→Welcome, жесты, позиция, GlassesOff→пауза) + `GlassesServer` (embeddedServer CIO + glassesServerModule + sender-адаптер GlassesSender поверх hub.broadcast)
- `NsdPublisher.kt` — mDNS-публикация
## Задача
### 1. Интерфейсы транспорта (lib-core, commonMain)
Создать `core/net/GlassesTransport.kt` (или аналогичный файл):
```kotlin
/** Транспорт связи очки↔телефон. Реализации: WiFi (WS), Bluetooth (RFCOMM). */
interface GlassesTransport {
val name: String // "wifi" / "bluetooth"
suspend fun connect(
onMessage: suspend (String) -> Unit, // входящее JSON-сообщение
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
)
suspend fun send(json: String)
fun close()
}
```
Сообщения передавать **JSON-строками** (декодирование остаётся на стороне вызывающего — в HostConnection и GlassesHub, как сейчас). Это держит транспорт полностью независимым от протокола.
Для телефона — интерфейс приёма подключений:
```kotlin
/** Приёмник подключений (телефон). */
interface GlassesServerTransport {
val name: String
suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: suspend (connId: String) -> Unit,
onDisconnected: suspend (connId: String) -> Unit,
)
suspend fun send(connId: String, json: String)
fun close()
}
```
(connId — идентификатор сессии; для WiFi можно использовать адрес/хэш, для BT — MAC-адрес устройства.)
### 2. WifiTransport (рефакторинг существующего)
- **Очки**: `WifiGlassesTransport` — обёртка над `GlassesWsClient`: connect пробрасывает колбэки, но строки (не декодированные объекты). Либо изменить `GlassesWsClient` на работу со строками — на твоё усмотрение, главное чтобы HostConnection перешёл на интерфейс.
- **Телефон**: `WifiServerTransport` — обёртка над Ktor-сервером: существующий `glassesServerModule`/`GlassesHub` переводится на интерфейс (сессии — connId-строки вместо DefaultWebSocketServerSession).
### 3. BluetoothTransport (новая реализация)
**Очки (клиент):**
- `BtGlassesTransport` — подключается к телефону через BluetoothSocket (RFCOMM, SPP): `BluetoothAdapter.getDefaultAdapter()`, найти сервис/устройство (bonded devices + поиск), `createRfcommSocketToServiceRecord(SPP_UUID)`, connect, потоки: читать строки (по разделителю/длине), писать строки.
- Discovery: bonded-устройства телефона (первый подходящий) или поиск (startDiscovery) — достаточно начать с bonded.
- Пермишены: BLUETOOTH, BLUETOOTH_ADMIN (до Android 12), BLUETOOTH_CONNECT/BLUETOOTH_SCAN (Android 12+, runtime-запрос).
**Телефон (сервер):**
- `BtServerTransport` — BluetoothServerSocket (RFCOMM, SPP, тот же UUID), accept-цикл, потоки чтения/записи на каждое подключение, connId = MAC устройства.
- Пермишены аналогично + FOREGROUND_SERVICE_CONNECTED_DEVICE (если нужно).
### 4. Соединение (фабрика/выбор)
- В `HostConnection` и `GlassesServer` — параметр транспорта (интерфейс), по умолчанию WifiTransport (чтобы не сломать текущее поведение). Выбор: константа/конфиг/аргумент конструктора. **По умолчанию — WiFi**, Bluetooth включается флагом (пользователь будет переключать вручную после проверки).
- Discovery (MdnsClient/PhoneScan/NsdPublisher) — оставить как часть WifiTransport; для BT — свой (bonded).
### 5. Сборка и тесты
- Существующие тесты (GlassesServerTest, HostToGlassesTest, GlassesToHostTest, PhoneActionsTest и др.) должны остаться зелёными — поведение WiFi не меняется.
- Добавить юнит-тесты на чистую логику (если есть что тестировать без Android: например, сериализация строк, выбор транспорта).
## Ограничения
- **НЕ менять протокол** (HostToGlasses/GlassesToHost/Json) — только способ доставки.
- НЕ ломать существующий WiFi-путь: он остаётся дефолтным и должен работать как раньше.
- Kotlin, стиль проекта (детали в соседних файлах).
- После рефакторинга — собрать `:app-phone:assembleDebug` и `:app-glasses:assembleDebug`, прогнать тесты, показать результат.
+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` (батч) не трогаем вообще.
+62
View File
@@ -0,0 +1,62 @@
# TASK-wav-header.md — починить размеры в WAV-заголовке (big-endian → little-endian)
## Симптом
Дампы `stt_dump_*.wav` (app-glasses WavWriter) не открываются в плеерах. Проверка hexdump:
- `RIFF 00 02 e1 a4` — размер записан big-endian (Java `RandomAccessFile.writeInt`), а WAV требует little-endian.
- Правильно: `a4 e1 02 00` для файла 188844 байт (data 188800).
## Точка правки
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt`, метод `close()`:
```kotlin
runCatching {
RandomAccessFile(path, "rw").use { raf ->
raf.seek(4)
raf.writeInt((36 + dataBytes).toInt()) // RIFF size — BIG-ENDIAN, БАГ
raf.seek(40)
raf.writeInt(dataBytes.toInt()) // data size — BIG-ENDIAN, БАГ
}
}
```
`headerBytes()` уже использует `ByteBuffer.order(LITTLE_ENDIAN)` — там всё правильно (placeholder -1 одинаков в любом порядке, поэтому баг не виден до close()).
## Фикс
Писать размеры little-endian. Минимальная правка без новых зависимостей:
```kotlin
runCatching {
RandomAccessFile(path, "rw").use { raf ->
raf.seek(4)
raf.write(leInt(36 + dataBytes))
raf.seek(40)
raf.write(leInt(dataBytes))
}
}
```
с helper рядом:
```kotlin
/** 4 байта little-endian (WAV-заголовок), в отличие от RandomAccessFile.writeInt (big-endian). */
private fun leInt(v: Long): ByteArray =
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()
```
(`ByteBuffer`/`ByteOrder` уже импортированы в файле.)
НЕ трогать: остальное в WavWriter (write/headerBytes), SttMicStream, охраняемые 4 файла, протокол.
## Тесты
В `app-glasses/src/test/.../stt/` (рядом с MicChunkTest) — новый `WavWriterTest`:
1. Записать 3200 байт через WavWriter во временный файл, close(), прочитать заголовок:
- байты 4..8 (RIFF size) == 36 + 3200 (little-endian, сравнить с ожидаемым ByteBuffer);
- байты 40..44 (data size) == 3200 (little-endian);
- python-wave-эквивалент: nframes == 1600 (можно проверить в JVM: прочитать заголовок вручную).
2. Пустой файл (0 байт данных) — RIFF size == 36, data size == 0.
3. Закрытие дважды (close два раза) — размеры не портятся (closed-флаг).
## Проверка
1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные (старые + новые).
2. `./gradlew :app-glasses:assembleDebug` — собирается.
3. Коммит: `stt(dump): WAV-заголовок — размеры little-endian (плееры открывают дампы)`. Push НЕ делать.
+116 -90
View File
@@ -1,106 +1,132 @@
# Задача: масштаб на всех экранах очков + сохранение скролла каталога # Задача: телефон не должен засыпать во время просмотра (foreground service)
## Баг 1: ConnectionScreen игнорирует масштаб ## Симптом (пользователь, 21.08)
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено) «Если я телефон отправлю спать, то звук на телефоне прекращается. А когда на
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется — телефоне прекращается звук, очки тоже перестают играть.»
сразу после старта масштаб 1».
**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в ## Корень (диагноз подтверждён)
`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` +
offset к центру). А `ConnectionScreen()` в
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354)
рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) —
scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть,
но на не-видео экране он не виден.
**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen: Архитектура: звук играет ТОЛЬКО на телефоне (у очков нет динамиков), видео —
на очках, синк по позиции (очки — мастер, 5с-цикл). На телефоне НЕТ
foreground service и НЕТ wake/wifi lock — приложение живёт только в Activity
(`app-phone/src/main/AndroidManifest.xml`: только MainActivity, пермишенов
WAKE_LOCK нет). При выключении экрана HONOR (Android 16, агрессивная фоновая
политика) замораживает/убивает процесс:
```kotlin 1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
fun ConnectionScreen() { автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
val connection = GlassesApp.instance.hostConnection 2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
Binocular(scale = false) { ## Решение
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK +
// масштаб через layout — как в GlassesMovieScreen WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране
val containerW = (screenWidthPx * scale).toInt() телефона. Очки тогда не теряют связь, видео не паузится, звук играет.
val containerH = (screenHeightPx * scale).toInt()
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз) ### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`)
Box(
modifier = Modifier ```xml
.width(containerW.px) <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
.height(containerH.px) <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
.offset(x = offsetX.px) <uses-permission android:name="android.permission.WAKE_LOCK" />
.background(Color.Black), <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
) { ```
... существующий контент ... Внутри `<application>` — сервис:
} ```xml
} <service
} android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
``` ```
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose, ### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
там есть `Side.LEFT/RIGHT` через CompositionLocal).
## Баг 2: скролл каталога теряется при навигации
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
папку, потом ещё — и т.д.»
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
**Фикс:** добавить в `CatalogState`:
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
В `CatalogScreen` composable:
```kotlin ```kotlin
val gridState = rememberLazyGridState() class PlaybackService : Service() {
// восстанавливаем позицию при смене пути/загрузке companion object {
LaunchedEffect(state.path, state.items) { const val CHANNEL_ID = "viewmate_playback"
val (idx, off) = state.savedScrollForCurrentPath() const val NOTIFICATION_ID = 1
gridState.scrollToItem(idx, off) // аккуратный запуск: ContextCompat.startForegroundService(context, intent)
} fun start(context: Context) {
// сохраняем позицию при скролле ContextCompat.startForegroundService(
LaunchedEffect(gridState) { context, Intent(context, PlaybackService::class.java)
snapshotFlow { )
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset }
}.collect { (idx, off) -> state.setScroll(idx, off) } fun stop(context: Context) {
context.stopService(Intent(context, PlaybackService::class.java))
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
// канал уведомлений (Android 8+)
val channel = NotificationChannel(
CHANNEL_ID, "ViewMate playback",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("ViewMate")
.setContentText("Звук на телефоне при просмотре на очках")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
// держим CPU и WiFi живыми при выключенном экране
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
?.apply { acquire() }
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
?.apply { acquire() }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
START_NOT_STICKY
override fun onDestroy() {
wakeLock?.takeIf { it.isHeld }?.release()
wifiLock?.takeIf { it.isHeld }?.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
} }
``` ```
и `LazyVerticalGrid(state = gridState, ...)`. Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется ### 3. Запуск/остановка сервиса в `PhoneApp.kt`
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
каждый уровень сохраняет свою позицию.
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`, - **Старт — при каждом старте аудио** (3 места):
`androidx.compose.runtime.snapshotFlow`. - `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
(строка ~127) и второй play (строка ~215)
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
приложение живо) — НО только если пользователь не против постоянного
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
уведомление — постоянный индикатор «ViewMate работает». Это проще и
покрывает и watch, и autostartAudio, и STT-сессии.
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
## Критерии приёмки Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
приложения-компаньона — так работает, например, в паре с очками).
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех. ### 4. Проверка
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу). 1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок. 2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
3. На железе (через 142): установить APK, запустить, начать просмотр,
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
проверить: звук играет, видео на очках играет (позиция растёт в логах),
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
+5
View File
@@ -61,6 +61,11 @@ dependencies {
implementation(project(":lib-core")) implementation(project(":lib-core"))
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
// implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
// WS-клиент очков (OkHttp — Android-движок) // WS-клиент очков (OkHttp — Android-движок)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
+7
View File
@@ -9,6 +9,13 @@
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" /> android:usesPermissionFlags="neverForLocation" />
<!-- Bluetooth SPP-транспорт (включается флагом GlassesConfig.USE_BLUETOOTH):
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<application <application
android:name=".GlassesApp" android:name=".GlassesApp"
@@ -0,0 +1,189 @@
package pw.binom.viewmate.glasses
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.net.BtTransportConstants
import pw.binom.viewmate.core.net.GlassesTransport
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Bluetooth-транспорт, сторона очков («клиент») — TASK-transport.md п.3:
* RFCOMM/SPP-клиент ([BtTransportConstants.SPP_UUID]): [connect] — бесконечный
* цикл подключения с реконнектом через [BtTransportConstants.RETRY_DELAY_MS]
* (цикл живёт внутри транспорта), строки-сообщения (JSON + перевод строки —
* разделитель) читаются на Dispatchers.IO, запись — под синхронизацией на
* сокете. Адрес телефона берётся из [GlassesConfig.btRemoteMac].
* Включается флагом [GlassesConfig.USE_BLUETOOTH] (дефолт — WiFi).
*/
class BtGlassesTransport(
private val context: Context,
) : GlassesTransport {
companion object {
/**
* Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
@SuppressLint("MissingPermission")
private fun hasBtPermissions(ctx: Context): Boolean =
runtimePermissions().all {
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
}
}
override val name = "bluetooth"
@Volatile
private var closed = false
@Volatile
private var socket: BluetoothSocket? = null
private val outLock = Any()
private var out: OutputStream? = null
/** Бесконечный цикл подключения+реконнекта: в корутине вызывающего, до [close]. */
@SuppressLint("MissingPermission")
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
while (!closed) {
var connected = false
if (socket == null) {
if (!ensureSocket()) {
log("bt", "SPP не создан (радио/пермишен/адрес?) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
delay(BtTransportConstants.RETRY_DELAY_MS)
continue
}
log("bt", "SPP-клиент готов: ${GlassesConfig.btRemoteMac()}")
}
try {
val sock = socket!!
withContext(Dispatchers.IO) { sock.connect() }
connected = true
out = sock.outputStream
log("bt", "SPP-канал открыт: ${GlassesConfig.btRemoteMac()}")
onConnected()
// readLines: EOF (партнёр закрыл) или исключение (обрыв/close)
readLines(sock, onMessage)
if (!closed) {
log("bt", "SPP-соединение оборвано")
onDisconnected()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "SPP-ошибка (${e::class.simpleName}: ${e.message}) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
if (!closed && connected) runCatching { onDisconnected() }
}
runCatching { out?.close() }
out = null
runCatching { socket?.close() }
socket = null
if (closed) break
delay(BtTransportConstants.RETRY_DELAY_MS)
}
}
/** RFCOMM/SPP-сокет к [GlassesConfig.btRemoteMac]; без радио/пермишена/адреса — false. */
@SuppressLint("MissingPermission")
private fun ensureSocket(): Boolean {
val mac = GlassesConfig.btRemoteMac()
if (mac.isNullOrBlank()) {
log("bt", "адрес телефона не задан (GlassesConfig.BT_REMOTE_MAC) — SPP недоступен")
return false
}
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
log("bt", "BT-радио не найдено — SPP недоступен")
return false
}
if (!adapter.isEnabled) {
log("bt", "Bluetooth выключен — включите в настройках Android")
return false
}
if (!hasBtPermissions(context)) {
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
return false
}
val dev = try {
adapter.getRemoteDevice(mac)
} catch (e: Exception) {
log("bt", "устройство не найдено: ${e.message}")
null
}
if (dev == null) return false
val s = try {
dev.createRfcommSocketToServiceRecord(UUID.fromString(BtTransportConstants.SPP_UUID))
} catch (e: Exception) {
log("bt", "не удалось создать SPP-сокет: ${e.message}")
null
}
if (s == null) return false
socket = s
return true
}
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
private suspend fun readLines(s: BluetoothSocket, onMessage: suspend (String) -> Unit) {
val reader = BufferedReader(InputStreamReader(s.inputStream))
while (!closed) {
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
if (line.isNotBlank()) onMessage(line)
}
runCatching { reader.close() }
}
/** JSON-строка в сокет: UTF-8 + разделитель, под синхронизацией. Без соединения — no-op. */
@SuppressLint("MissingPermission")
override suspend fun send(json: String) {
val o = out ?: return
runCatching {
synchronized(outLock) {
o.write(json.toByteArray(Charsets.UTF_8))
o.write(BtTransportConstants.NEWLINE.code)
o.flush()
}
}.onFailure { log("bt", "запись не удалась: ${it.message}") }
}
override fun close() {
closed = true
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
}
}
@@ -6,11 +6,27 @@ import com.rayneo.arsdk.android.MercurySDK
import java.io.File import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
data class ChatEntry(val role: String, val text: String)
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */ /** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
object GlassesConfig { object GlassesConfig {
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон // Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
// находится авто-обнаружением по mDNS (_viewmate._tcp). // находится авто-обнаружением по mDNS (_viewmate._tcp).
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses" const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
/**
* Выбор транспорта (TASK-transport.md п.4): `false` (дефолт) — WiFi
* ([WifiGlassesTransport], поведение без изменений); `true` — Bluetooth SPP
* ([BtGlassesTransport], manual-включение).
*/
const val USE_BLUETOOTH = true
/** MAC-адрес телефона-партнёра для SPP (нужен только при USE_BLUETOOTH=true). */
val BT_REMOTE_MAC: String? = "08:84:FB:D5:BD:3E"
/** Адрес SPP-партнёра (null → SPP недоступен, транспорт ляжет с ретраями). */
fun btRemoteMac(): String? = BT_REMOTE_MAC
} }
/** /**
@@ -36,6 +52,9 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */ /** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
val scale = MutableStateFlow(1f) val scale = MutableStateFlow(1f)
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */ /** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
val listening = MutableStateFlow(false) val listening = MutableStateFlow(false)
@@ -45,6 +64,27 @@ class GlassesApp : Application() {
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */ /** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("") 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/выход/вход в режим:
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
*/
val thinking = MutableStateFlow(false)
/**
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
*/
val answerScrollDelta = MutableStateFlow(0f)
/** Текущая Activity (для закрытия приложения по команде с телефона). */ /** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null var mainActivity: MainActivity? = null
@@ -55,10 +95,18 @@ class GlassesApp : Application() {
prefs.edit().putFloat("scale", v).apply() prefs.edit().putFloat("scale", v).apply()
} }
/** Применить и сохранить вертикаль (0..1). */
fun applyVertical(value: Float) {
val v = value.coerceIn(0f, 1f)
vertical.value = v
prefs.edit().putFloat("vertical", v).apply()
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f) scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
MercurySDK.init(this) MercurySDK.init(this)
movieController = MovieController(applicationContext) movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") } movieController.eventLogger = { msg -> logToFile("mc: $msg") }
@@ -12,13 +12,18 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.net.GlassesWsClient 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.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.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles import pw.binom.viewmate.core.protocol.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles import pw.binom.viewmate.core.protocol.DownloadFiles
@@ -31,19 +36,22 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.SetVertical
import pw.binom.viewmate.core.protocol.Shutdown import pw.binom.viewmate.core.protocol.Shutdown
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
/** /**
* Обвязка над [GlassesWsClient] для UI очков: * Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект, * mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
* Hello при подключении, периодический статус/позиция, * WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером, * периодический статус/позиция, обработка входящих HostToGlasses (строкой из
* DownloadFiles/DeleteFiles — менеджером скачивания). * транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
*/ */
class HostConnection( class HostConnection(
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */ /** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
@@ -56,11 +64,14 @@ class HostConnection(
private val discovery = MdnsClient(context) private val discovery = MdnsClient(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */ /** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
@Volatile private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
private var client: GlassesWsClient? = null
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */ /** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
@Volatile
private var transport: GlassesTransport? = null
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
@Volatile @Volatile
private var lastUrl: String? = null private var lastUrl: String? = null
@@ -81,10 +92,10 @@ class HostConnection(
scope.launch { scope.launch {
while (isActive) { while (isActive) {
delay(5_000) delay(5_000)
client?.send(buildStatus()) sendToHost(buildStatus())
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() } val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
if (snapshot.playing) movieController.markPosition(snapshot.positionMs) if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
client?.send( sendToHost(
PlaybackPosition( PlaybackPosition(
positionMs = snapshot.positionMs, positionMs = snapshot.positionMs,
playing = snapshot.playing, playing = snapshot.playing,
@@ -96,36 +107,80 @@ class HostConnection(
} }
} }
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
private suspend fun connectLoop() {
if (useBt) connectLoopBt() else connectLoopWifi()
}
/** /**
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на * Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б). * 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при * Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается). * пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же * Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
* адресом (переиспользование последнего найденного); при его отсутствии — * адресом (переиспользование последнего найденного); при его отсутствии —
* повторный mDNS-цикл. * повторный mDNS-цикл.
*/ */
private suspend fun connectLoop() { private suspend fun connectLoopWifi() {
val url = lastUrl ?: discoverPhoneUrl() val url = lastUrl ?: discoverPhoneUrl()
if (url.isEmpty()) { if (url.isEmpty()) {
log("app", "телефон не найден — повтор поиска через 3с") log("app", "телефон не найден — повтор поиска через 3с")
delay(3_000) delay(3_000)
connectLoop() connectLoopWifi()
return return
} }
lastUrl = url lastUrl = url
log("app", "подключаюсь к $url") log("app", "подключаюсь к $url (WiFi)")
val c = GlassesWsClient(url) val t = WifiGlassesTransport(url)
client = c transport = t
c.connect( t.connect(
onHostMessage = { msg -> handleHostMessage(msg) }, onMessage = { text -> handleHostMessageText(text) },
onConnected = { onConnected = {
_connected.value = true _connected.value = true
_status.value = "подключено: $url" _status.value = "подключено: ${t.name} $url"
addMessage("соединение установлено") addMessage("соединение установлено")
discovery.stop() discovery.stop()
c.send(Hello(appVersion = APP_VERSION)) sendToHost(Hello(appVersion = APP_VERSION))
if (reconnectResume.consume()) {
withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю")
}
},
onDisconnected = {
_connected.value = false
_status.value = "нет соединения (реконнект через 3с)"
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
reconnectResume.onConnectionLost(wasPlaying)
if (wasPlaying) addMessage("связь потеряна — пауза")
},
)
}
/**
* SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено
* вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect].
*/
private suspend fun connectLoopBt() {
val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "")
if (mac.isEmpty()) {
log("app", "BT: MAC-адрес телефона не задан — SPP недоступен")
delay(3_000)
connectLoopBt()
return
}
lastUrl = mac
log("app", "подключаюсь к $mac (Bluetooth SPP)")
val t = BtGlassesTransport(appContext)
transport = t
t.connect(
onMessage = { text -> handleHostMessageText(text) },
onConnected = {
_connected.value = true
_status.value = "подключено: bluetooth $mac"
addMessage("соединение установлено")
sendToHost(Hello(appVersion = APP_VERSION))
if (reconnectResume.consume()) { if (reconnectResume.consume()) {
withContext(Dispatchers.Main) { movieController.resume() } withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю") addMessage("связь восстановлена — продолжаю")
@@ -145,7 +200,7 @@ class HostConnection(
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б). * Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу * На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при * наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]). * неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
*/ */
private suspend fun discoverPhoneUrl(): String { private suspend fun discoverPhoneUrl(): String {
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator())) val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
@@ -171,18 +226,29 @@ class HostConnection(
return "" return ""
} }
/** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */
private suspend fun handleHostMessageText(text: String) {
val msg = runCatching {
protocolJson.decodeFromString(HostToGlasses.serializer(), text)
}.onFailure {
log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})")
}.getOrNull() ?: return
handleHostMessage(msg)
}
/** Отправка сообщения на телефон (используется менеджером скачивания). */ /** Отправка сообщения на телефон (используется менеджером скачивания). */
suspend fun sendToHost(msg: GlassesToHost) { suspend fun sendToHost(msg: GlassesToHost) {
client?.send(msg) val t = transport ?: return
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
} }
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */ /** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
fun sendPlaybackPositionNow() { fun sendPlaybackPositionNow() {
val c = client ?: return if (transport == null) return
scope.launch { scope.launch {
val (positionMs, playing, itemId, audioIndex) = val (positionMs, playing, itemId, audioIndex) =
withContext(Dispatchers.Main) { movieController.playbackPosition() } withContext(Dispatchers.Main) { movieController.playbackPosition() }
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex)) sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
} }
} }
@@ -244,6 +310,11 @@ class HostConnection(
log("app", "scale → ${msg.scale}") log("app", "scale → ${msg.scale}")
} }
is SetVertical -> {
GlassesApp.instance.applyVertical(msg.vertical)
log("app", "vertical → ${msg.vertical}")
}
is PlayVideo -> { is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}") addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset() reconnectResume.reset()
@@ -270,11 +341,20 @@ class HostConnection(
is ShowText -> { is ShowText -> {
addMessage("ShowText: ${msg.text}") addMessage("ShowText: ${msg.text}")
GlassesApp.instance.thinking.value = false
GlassesApp.instance.answerScrollDelta.value = 0f
GlassesApp.instance.assistantText.value = msg.text GlassesApp.instance.assistantText.value = msg.text
} }
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") 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 -> { is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full GlassesApp.instance.sttText.value = msg.full
log("stt", "фраза: ${msg.phrase}") log("stt", "фраза: ${msg.phrase}")
@@ -286,8 +366,31 @@ class HostConnection(
} }
is SttCancel -> { is SttCancel -> {
log("stt", "отменено") when (msg.reason) {
SttCancel.REASON_CANCEL -> {
// Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить
// listening/thinking, НО остаться в режиме (показать последний ответ),
// микрофон заново НЕ открывать, из режима НЕ выходить.
log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме")
GlassesApp.instance.listening.value = false 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 -> { is Shutdown -> {
@@ -306,7 +409,7 @@ class HostConnection(
} }
fun stop() { fun stop() {
client?.close() transport?.close()
discovery.stop() discovery.stop()
scope.cancel() scope.cancel()
} }
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -18,7 +19,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -36,7 +36,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -51,10 +50,14 @@ import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.SttAudio import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.StopStt import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.GestureDecision
import pw.binom.viewmate.glasses.gestures.actionName import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand import pw.binom.viewmate.glasses.gestures.assistantMode
import pw.binom.viewmate.glasses.gestures.gestureDecision
import pw.binom.viewmate.glasses.status.StatusPanelCompose import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import pw.binom.viewmate.glasses.ui.Binocular import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.BinocularScreenSize
@@ -62,6 +65,7 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX import pw.binom.viewmate.glasses.ui.scaleOffsetX
import pw.binom.viewmate.glasses.ui.panelOffsetX
class MainActivity : BaseEventActivity() { class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false private var resumeAfterScreenOn = false
@@ -79,9 +83,9 @@ class MainActivity : BaseEventActivity() {
private val debugReceiver = object : BroadcastReceiver() { private val debugReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.getStringExtra("type")) { when (intent?.getStringExtra("type")) {
"triple_click" -> handleTripleClick() "triple_click" -> onTempleAction(TempleAction.TripleClick())
"click" -> handleClick() "click" -> onTempleAction(TempleAction.Click())
"double_click" -> handleDoubleClick() "double_click" -> onTempleAction(TempleAction.DoubleClick())
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}") else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
} }
} }
@@ -116,6 +120,7 @@ class MainActivity : BaseEventActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
GlassesApp.instance.mainActivity = this GlassesApp.instance.mainActivity = this
requestBtPermissions()
collectTempleActions() collectTempleActions()
disableTrackpadSounds() disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -135,6 +140,9 @@ class MainActivity : BaseEventActivity() {
} else { } else {
ConnectionScreen() ConnectionScreen()
} }
// Оверлей режима общения: поверх любого экрана (затемняет кино),
// виден в «слушаю»/«думаю»/«ответ виден».
AssistantOverlay()
} }
} }
} }
@@ -188,78 +196,162 @@ class MainActivity : BaseEventActivity() {
}, null) }, null)
} }
/** private fun requestBtPermissions() {
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick — if (!GlassesConfig.USE_BLUETOOTH) return
* голосовой ввод (безусловный вызов), Click во время прослушивания — val granted = BtGlassesTransport.runtimePermissions().all {
* завершить распознавание, DoubleClick — отмена. checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
*/ }
if (granted) return
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
}
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
private fun collectTempleActions() { private fun collectTempleActions() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action -> templeActionViewModel.state.collect { action ->
when (action) { onTempleAction(action)
is TempleAction.TripleClick -> handleTripleClick()
is TempleAction.Click -> handleClick()
is TempleAction.DoubleClick -> handleDoubleClick()
is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
val cmd = templeActionToCommand(action)
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
if (cmd != null) {
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
}
else -> log("gesture", "жест (резерв): ${actionName(action)}")
}
} }
} }
} }
} }
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */ /**
private fun handleTripleClick() { * Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
if (GlassesApp.instance.listening.value) { * (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
*/
private fun onTempleAction(action: TempleAction) {
val app = GlassesApp.instance
val state = AssistantState(
listening = app.listening.value,
thinking = app.thinking.value,
sttText = app.sttText.value,
assistantText = app.assistantText.value,
)
when (val decision = gestureDecision(action, state)) {
GestureDecision.Ignore ->
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
GestureDecision.CallAssistant -> startAssistantMode()
GestureDecision.TogglePlayback -> togglePlayback()
GestureDecision.SendPhrase -> sendPhrase()
GestureDecision.NewPhrase -> startNewPhrase()
GestureDecision.CancelDictation -> cancelDictation()
GestureDecision.ExitMode -> exitAssistantMode()
is GestureDecision.Seek -> seekBy(decision)
is GestureDecision.ScrollAnswer -> {
val acc = app.answerScrollDelta.value + decision.delta
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
app.answerScrollDelta.value = acc
}
}
}
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
private fun startAssistantMode() {
val app = GlassesApp.instance
if (app.listening.value) {
log("gesture", "TripleClick проигнорирован: распознавание уже идёт") log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
return return
} }
log("stt", "ассистент: слушаю") log("stt", "ассистент: слушаю")
if (GlassesApp.instance.movieController.pauseIfPlaying()) { if (app.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick)") log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
} }
GlassesApp.instance.listening.value = true GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
GlassesApp.instance.sttText.value = "" app.listening.value = true
GlassesApp.instance.assistantText.value = "" app.thinking.value = false
sttMic.start(filesDir) { GlassesApp.instance.listening.value } app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
sttMic.start(filesDir) { app.listening.value }
} }
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */ /** Click (кино): плей/пауза — как сегодня. */
private fun handleClick() { private fun togglePlayback() {
if (GlassesApp.instance.listening.value) {
log("gesture", "Click → завершить распознавание")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
return
}
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}") log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) { GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
} }
} }
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */ /** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
private fun handleDoubleClick() { private fun sendPhrase() {
if (GlassesApp.instance.listening.value) { log("gesture", "Click → отправить фразу")
sttMic.stop()
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = true
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
}
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
private fun startNewPhrase() {
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
val app = GlassesApp.instance
app.assistantText.value = ""
app.sttText.value = ""
app.thinking.value = false
app.answerScrollDelta.value = 0f
app.listening.value = true
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 → отмена распознавания") log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop() sttMic.stop()
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = false
lifecycleScope.launch { lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true)) GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
} }
return
} }
log("gesture", "жест (резерв): DoubleClick")
/** 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
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
}
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
private fun seekBy(seek: GestureDecision.Seek) {
log("gesture", "жест: свайп → ${seek.command}")
GlassesApp.instance.movieController.applyMediaCommand(
MediaCommandMsg(seek.command, seek.seekMs)
) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
} }
/** /**
@@ -287,11 +379,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", "экран погас — пауза")
} }
@@ -373,11 +472,17 @@ fun ConnectionScreen() {
val containerW = (screenWidthPx * scale).toInt() val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt() val containerH = (screenHeightPx * scale).toInt()
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
Box(Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(containerW.px) .width(containerW.px)
.height(containerH.px) .height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px) .offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
// вертикаль (vertical) — только для видео; здесь контент всегда у верха
)
.background(Color.Black), .background(Color.Black),
) { ) {
Column( Column(
@@ -394,46 +499,16 @@ fun ConnectionScreen() {
} }
} }
} }
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
}
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier
scale = scale, .align(Alignment.BottomCenter)
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
.padding(bottom = 10.dp),
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
) )
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
// (assistantText) — ответ читается до новой сессии.
val listening by GlassesApp.instance.listening.collectAsState()
val sttText by GlassesApp.instance.sttText.collectAsState()
val assistantText by GlassesApp.instance.assistantText.collectAsState()
if (listening || assistantText.isNotBlank()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f)),
) {
Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (sttText.isBlank()) "слушаю..." else sttText,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
if (assistantText.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Text(
assistantText,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
}
} }
} }
} }
@@ -0,0 +1,151 @@
package pw.binom.viewmate.glasses.gestures
import com.rayneo.arsdk.android.touch.TempleAction
import pw.binom.viewmate.core.MediaCommand
/**
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
*
* @NONE — кино: ассистент не вызван.
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
* @ANSWER — ответ виден: listening=false и assistantText непуст.
*/
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState(
/** Микрофон открыт (TripleClick → слушаю). */
val listening: Boolean = false,
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
val thinking: Boolean = false,
/** Накопленные фразы (SttPhrase, full). */
val sttText: String = "",
/** Ответ ассистента (ShowText). */
val assistantText: String = "",
)
/**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*/
fun assistantMode(state: AssistantState): AssistantMode = when {
state.listening -> AssistantMode.LISTENING
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING
else -> AssistantMode.NONE
}
/**
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
* Матрица (docs/gestures-and-modes.md):
*
* | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------|
* | Кино | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | отмена | игнор | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/
sealed interface GestureDecision {
/** Жест в данном режиме не назначен (напр. DoubleClick в кино, свайпы в «слушаю»). */
data object Ignore : GestureDecision
/** TripleClick (кино): пауза фильма + открыть микрофон. */
data object CallAssistant : GestureDecision
/** Клик в кино: плей/пауза (как сегодня, MediaCommand.TOGGLE). */
data object TogglePlayback : GestureDecision
/** Клик в «слушаю»: закрыть микрофон, отправить стоп-STT (фраза на телефон). */
data object SendPhrase : GestureDecision
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
data object NewPhrase : GestureDecision
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */
data object CancelDictation : GestureDecision
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
data object ExitMode : GestureDecision
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
data class Seek(val command: MediaCommand, val seekMs: Long) : GestureDecision
/** Свайпы при «ответе виден»: дельта скролла ответа, px. Положительная — к концу текста. */
data class ScrollAnswer(val delta: Float) : GestureDecision
}
/**
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
*
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
* меняем знак здесь, в одной точке).
*/
fun gestureDecision(
action: TempleAction,
state: AssistantState,
seekStepMs: Long = 30_000,
pageDeltaPx: Float = 200f,
): GestureDecision = when (val mode = assistantMode(state)) {
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
AssistantMode.NONE -> when (action) {
is TempleAction.Click -> GestureDecision.TogglePlayback
is TempleAction.TripleClick -> GestureDecision.CallAssistant
else ->
templeActionToCommand(action, seekStepMs)?.let { GestureDecision.Seek(it.command, it.seekMs) }
?: GestureDecision.Ignore
}
AssistantMode.LISTENING -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
else -> GestureDecision.Ignore
}
AssistantMode.THINKING -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
AssistantMode.ANSWER -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
is TempleAction.SlideContinuous ->
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
// два строка выше.
GestureDecision.ScrollAnswer(action.delta)
else -> GestureDecision.Ignore
}
}
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank()
/**
* Заголовок оверлея (над ответом). null — только ответ.
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт;
* - при «ответе виден» — последняя фраза как контекст (или null).
*/
fun overlayHeader(state: AssistantState): String? = when {
state.thinking && state.assistantText.isBlank() -> "думаю..."
state.listening -> state.sttText.ifBlank { "слушаю..." }
else -> state.sttText.takeIf { it.isNotBlank() }
}
/**
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
* Чистая функция — unit-тесты без Android.
*/
fun scrollOffset(currentOffset: Int, delta: Float, maxScroll: Int): Int =
(currentOffset + delta).toInt().coerceIn(0, maxScroll)
@@ -3,8 +3,6 @@ package pw.binom.viewmate.glasses.stt
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioRecord import android.media.AudioRecord
import android.media.MediaRecorder import android.media.MediaRecorder
import java.io.File
import java.io.FileInputStream
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -12,14 +10,22 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.glasses.log import pw.binom.viewmate.glasses.log
import java.io.File
import java.io.FileInputStream
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
/** /**
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк. * Поток микрофона очков (TASK-mic): 16 кГц s16 mono, полный поток БЕЗ VAD-нарезки.
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
* filesDir/samples (отладка, см. TASK.md этап 2).
* *
* Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening): * История: WebRTC VAD резал речь на огрызки — микрофон ARGT78 пишет рвано
* как только очки перестали слушать, поток обрывается. * (пачки ~50 мс звука / ~50 мс тишины), VAD принимал внутренние провалы за паузы
* и ловил лишь ~15% речи. Локальный тест whisper на сыром дампе («1,2,3,4,5
* 6,7,8,9,10») распознал полный файл идеально — whisper сам справляется с
* рваностью и тихим уровнем (пик ~1500). Поэтому: весь PCM уходит на телефон
* чанками по 100 мс, усиление ×3 (без клиппа), телефон копит полный буфер
* сессии и шлёт его в whisper по клику.
*/ */
class SttMicStream( class SttMicStream(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -27,42 +33,64 @@ class SttMicStream(
) { ) {
private var job: Job? = null private var job: Job? = null
/** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */
private var dump: WavWriter? = null
fun start(filesDir: File, shouldRun: () -> Boolean) { fun start(filesDir: File, shouldRun: () -> Boolean) {
job?.cancel() job?.cancel()
val minBuffer = AudioRecord.getMinBufferSize(
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
)
val bufferBytes = maxOf(minBuffer, MIC_CHUNK_FRAMES * FRAME_SIZE * 2)
val recorder = runCatching { val recorder = runCatching {
AudioRecord( AudioRecord(
MediaRecorder.AudioSource.MIC, MediaRecorder.AudioSource.MIC,
16_000, MIC_SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT,
6_400, bufferBytes,
) )
}.getOrNull() }.getOrNull()
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) { if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder?.release() recorder?.release()
log("stt", "микрофон недоступен — отладка: PCM из файла") log("stt", "микрофон недоступен — отладка: PCM из файла")
dump = null
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) } job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
return return
} }
dump = openLiveMicDump(filesDir)
val dumpWriter = dump
job = scope.launch(Dispatchers.IO) { job = scope.launch(Dispatchers.IO) {
try { try {
recorder.startRecording() recorder.startRecording()
log("stt", "микрофон запущен (16 кГц, s16, mono)") log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)")
val buf = ByteArray(3_200) val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс
val chunk = ArrayList<ShortArray>(MIC_CHUNK_FRAMES)
while (isActive && shouldRun()) { while (isActive && shouldRun()) {
val n = recorder.read(buf, 0, buf.size) val n = recorder.read(frame, 0, FRAME_SIZE) // кадры
if (n > 0) { if (n <= 0) continue
val chunk = if (n == buf.size) buf else buf.copyOf(n) val data = if (n == FRAME_SIZE) frame else frame.copyOf(n)
onChunk(chunk) // СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки.
if (dumpWriter != null) {
runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
}
chunk.add(gainShort(data, MIC_GAIN))
if (chunk.size >= MIC_CHUNK_FRAMES) {
onChunk(shortsToPcm16(chunk))
chunk.clear()
} }
} }
if (chunk.isNotEmpty()) onChunk(shortsToPcm16(chunk)) // остаток < 100 мс
} catch (t: Throwable) { } catch (t: Throwable) {
log("stt", "микрофон: ${t.message}") log("stt", "микрофон: ${t.message}")
} finally { } finally {
runCatching { recorder.stop() } runCatching { recorder.stop() }
recorder.release() recorder.release()
runCatching { dumpWriter?.close() }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
log("stt", "микрофон остановлен") log("stt", "микрофон остановлен")
} }
} }
@@ -71,6 +99,23 @@ class SttMicStream(
fun stop() { fun stop() {
job?.cancel() job?.cancel()
job = null job = null
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
}
/**
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
* открыть stt_dump_HHmmss.wav — сырой поток микрофона допишется туда
* (эмуляторную ветку не трогаем).
*/
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 +151,115 @@ class SttMicStream(
} }
} }
/** Фрейм чтения микрофона: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
internal const val FRAME_SIZE = 320
/** Чанк на телефон: 100 мс = 5 фреймов по 20 мс. */
internal const val MIC_CHUNK_FRAMES = 5
/**
* Коэффициент усиления микрофона: ×3 (без клиппа — ARGT78 пишет пик 1–9%, ×3 даёт
* 3–27%). Whisper распознал и сырой (пик 1521), и ×3, и ×10 — усиление нужно
* только чтобы телефонный whisper.cpp не терял тихие участки.
*/
internal const val MIC_GAIN = 3f
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
internal const val CHUNK_BYTES = 3_200
/** Микрофон очков: 16 кГц — единственная рабочая частота ARGT78 (48 кГц молчит). */
internal const val MIC_SAMPLE_RATE = 16_000
/**
* Цифровое усиление фрейма s16le mono с насыщением (для whisper).
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
*/
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
if (gain == 1f) return samples
val out = ShortArray(samples.size)
for (i in samples.indices) {
out[i] = (samples[i] * gain).roundToInt().coerceIn(-32_768, 32_767).toShort()
}
return out
}
/** Склейка фреймов (ShortArray) в один s16le ByteArray. */
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
var total = 0
for (f in frames) total += f.size
val out = ByteArray(total * 2)
var i = 0
for (f in frames) {
for (s in f) {
out[i++] = (s.toInt() and 0xff).toByte()
out[i++] = ((s.toInt() shr 8) and 0xff).toByte()
}
}
return out
}
/**
* Даунсэмпл 48→16 кГц (сохранён для тестов; в проде не используется — 16 кГц напрямую):
* склейка 48 кГц-фреймов, FIR (скользящее среднее 3) против алиасинга, каждый 3-й сэмпл.
*/
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
var total = 0
for (f in frames) total += f.size
val outSize = total / 3
val out = ShortArray(outSize)
var src = 0
var dst = 0
var prev2 = 0
var prev1 = 0
for (f in frames) {
for (s in f) {
if (src % 3 == 2) {
val v = ((prev2 + prev1 + s) / 3)
if (dst < outSize) out[dst++] = v.toShort()
}
prev2 = prev1
prev1 = s.toInt()
src++
}
}
return listOf(out)
}
/**
* 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)
}
/**
* Цифровое усиление чанка s16le mono с насыщением.
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
*/
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray {
if (gain == 1f) return bytes
val out = ByteArray(bytes.size)
var i = 0
var j = 0
while (i + 1 < bytes.size) {
val lo = bytes[i].toInt() and 0xff
val hi = bytes[i + 1].toInt() and 0xff
i += 2
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
val v = (s * gain).roundToInt().coerceIn(-32_768, 32_767)
out[j++] = (v and 0xff).toByte()
out[j++] = ((v shr 8) and 0xff).toByte()
}
return out
}
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */ /** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray { internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
val out = ByteArray(samples.size * 2) val out = ByteArray(samples.size * 2)
@@ -0,0 +1,72 @@
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.write(leInt(36 + dataBytes)) // размер RIFF (little-endian)
raf.seek(40)
raf.write(leInt(dataBytes)) // размер data (little-endian)
}
}
}
/** 4 байта little-endian (WAV-заголовок), в отличие от RandomAccessFile.writeInt (big-endian). */
private fun leInt(v: Long): ByteArray =
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()
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()
}
@@ -0,0 +1,190 @@
package pw.binom.viewmate.glasses.ui
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
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
import pw.binom.viewmate.glasses.gestures.overlayVisible
import pw.binom.viewmate.glasses.gestures.scrollOffset
/**
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
* [GlassesApp.answerScrollDelta]).
*/
@Composable
fun AssistantOverlay() {
val app = GlassesApp.instance
val listening by app.listening.collectAsState()
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
// Скролл и эффекты живут ВЫШЕ 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,
chatHistory: List<ChatEntry>,
scrollState: ScrollState,
) {
Binocular(scale = false) {
val scale by app.scale.collectAsState()
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
// вертикаль (vertical) — только для видео; оверлей всегда у верха
)
.background(Color.Black.copy(alpha = 0.72f)),
) {
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,
chatHistory: List<ChatEntry>,
scrollState: ScrollState,
) {
val hasHistory = chatHistory.isNotEmpty()
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
val header = overlayHeader(state)
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center,
) {
if (!header.isNullOrBlank()) {
Text(
text = header,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
}
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
.fillMaxWidth()
.weight(1f)
.verticalScroll(scrollState),
) {
Text(
text = "ассистент: ${state.assistantText}",
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
)
}
}
}
}
@@ -57,6 +57,7 @@ fun GlassesMovieScreen(
var videoHeight by remember { mutableIntStateOf(480) } var videoHeight by remember { mutableIntStateOf(480) }
val scale by GlassesApp.instance.scale.collectAsState() val scale by GlassesApp.instance.scale.collectAsState()
val vertical by GlassesApp.instance.vertical.collectAsState()
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый // Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView. // глаз из Binocular) регистрируют в нём свои SurfaceView.
@@ -103,14 +104,23 @@ fun GlassesMovieScreen(
maxHeight = containerH - 25, maxHeight = containerH - 25,
) )
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
Box(Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(containerW.px) .width(containerW.px)
.height(containerH.px) .height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px), .offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().background(Color.Black), modifier = Modifier.fillMaxWidth().background(Color.Black),
// Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
// Row-центрирование съедало вертикаль — картинка оставалась «по центру».
verticalAlignment = Alignment.Top,
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start, horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
) { ) {
if (SCALE_DEBUG_SQUARE) { if (SCALE_DEBUG_SQUARE) {
@@ -121,16 +131,20 @@ fun GlassesMovieScreen(
BinocularVideoPlayer( BinocularVideoPlayer(
player = player, player = player,
renderer = renderer, renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px), modifier = Modifier
.size(playerWidth.px, playerHeight.px)
.offset(y = videoOffsetY(containerH, playerHeight, vertical).px),
) )
} }
} }
}
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
// своей половины, бинокулярно сливается в одну по центру.
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier
scale = scale, .align(Alignment.BottomCenter)
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
.padding(bottom = 10.dp),
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
) )
} }
} }
@@ -143,6 +157,30 @@ fun GlassesMovieScreen(
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int = fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0 if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
/**
* Вертикальное смещение контейнера глаза (с учётом масштаба):
* vertical = 1.0 — у верха (как всегда было); 0.0 — нижняя кромка контейнера
* прижата к низу экрана. Свободный ход по вертикали = (1 - scale) высоты.
*/
fun verticalOffsetY(screenHeightPx: Int, scale: Float, vertical: Float): Int =
((1f - vertical) * screenHeightPx * (1f - scale)).toInt()
/**
* Смещение видео внутри контейнера глаза: vertical=1.0 — у верха контейнера,
* 0.0 — у низа (нижняя кромка видео прижата к низу контейнера, который сам
* у низа экрана при vertical=0). Row-центрирование иначе съедало вертикаль.
*/
fun videoOffsetY(containerHeightPx: Int, videoHeightPx: Int, vertical: Float): Int =
((1f - vertical) * (containerHeightPx - videoHeightPx).coerceAtLeast(0)).toInt()
/**
* Горизонтальный сдвиг панели статуса к центру (к носу), чтобы её центр совпадал
* с центром контейнера при любом масштабе: LEFT — вправо, RIGHT — влево,
* на половину хода контейнера ((1 - scale) * W / 2).
*/
fun panelOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
((1f - scale) * screenWidthPx / 2f).toInt() * if (side == Side.LEFT) 1 else -1
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */ /** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
private const val SCALE_DEBUG_SQUARE = false private const val SCALE_DEBUG_SQUARE = false
@@ -4,6 +4,9 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.scaleOffsetX import pw.binom.viewmate.glasses.ui.scaleOffsetX
import pw.binom.viewmate.glasses.ui.verticalOffsetY
import pw.binom.viewmate.glasses.ui.videoOffsetY
import pw.binom.viewmate.glasses.ui.panelOffsetX
/** /**
* scaleOffsetX — смещение контейнера глаза к центру (к носу): * scaleOffsetX — смещение контейнера глаза к центру (к носу):
@@ -34,4 +37,81 @@ class ScaleOffsetTest {
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f)) assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f)) assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
} }
/*
* verticalOffsetY — вертикальное положение контейнера с учётом масштаба:
* 1.0 — у верха (offset 0), 0.0 — нижняя кромка у низа ((1 - scale) высоты).
*/
@Test
fun verticalTop_isZero() {
assertEquals(0, verticalOffsetY(480, 1f, 1f))
assertEquals(0, verticalOffsetY(480, 0.7f, 1f))
assertEquals(0, verticalOffsetY(480, 0.5f, 1f))
}
@Test
fun verticalBottom_pushesToBottomEdge() {
// 0.7 scale: свободный ход = 480 * 0.3 = 144
assertEquals(144, verticalOffsetY(480, 0.7f, 0f))
// 1.0 scale: хода нет — экран и так на весь дисплей
assertEquals(0, verticalOffsetY(480, 1f, 0f))
}
@Test
fun verticalMiddle_halfOfFreeRun() {
// 0.7 scale, vertical 0.5: (1-0.5)*480*0.3 = 72
assertEquals(72, verticalOffsetY(480, 0.7f, 0.5f))
assertEquals(0, verticalOffsetY(480, 1f, 0.5f))
}
/*
* videoOffsetY — видео внутри контейнера: верх при vertical=1, низ при 0.
*/
@Test
fun videoTop_zero() {
assertEquals(0, videoOffsetY(432, 268, 1f))
}
@Test
fun videoBottom_pushedToContainerBottom() {
// 432 контейнер, 268 видео: ход = 164
assertEquals(164, videoOffsetY(432, 268, 0f))
}
@Test
fun videoMiddle_halfOfRun() {
assertEquals(82, videoOffsetY(432, 268, 0.5f))
}
@Test
fun videoLargerThanContainer_noNegativeOffset() {
assertEquals(0, videoOffsetY(200, 268, 0f), "видео выше контейнера — хода нет")
}
/*
* panelOffsetX — панель статуса сдвигается к носу, как контейнер:
* LEFT — вправо, RIGHT — влево, на (1 - scale) * W / 2.
*/
@Test
fun panelFullScale_noOffset() {
assertEquals(0, panelOffsetX(Side.LEFT, 640, 1f))
assertEquals(0, panelOffsetX(Side.RIGHT, 640, 1f))
}
@Test
fun panelHalfScale_movesTowardNose() {
// 640 * 0.5 / 2 = 160: LEFT вправо, RIGHT влево
assertEquals(160, panelOffsetX(Side.LEFT, 640, 0.5f))
assertEquals(-160, panelOffsetX(Side.RIGHT, 640, 0.5f))
}
@Test
fun panelScale07_offset() {
// 640 * 0.3 / 2 = 96
assertEquals(96, panelOffsetX(Side.LEFT, 640, 0.7f))
assertEquals(-96, panelOffsetX(Side.RIGHT, 640, 0.7f))
}
} }
@@ -0,0 +1,255 @@
package pw.binom.viewmate.glasses.gestures
import android.view.MotionEvent
import com.rayneo.arsdk.android.touch.FlingArgs
import com.rayneo.arsdk.android.touch.TempleAction
import com.rayneo.arsdk.android.touch.TempleAction.Click
import com.rayneo.arsdk.android.touch.TempleAction.DoubleClick
import com.rayneo.arsdk.android.touch.TempleAction.SlideBackward
import com.rayneo.arsdk.android.touch.TempleAction.SlideContinuous
import com.rayneo.arsdk.android.touch.TempleAction.SlideForward
import com.rayneo.arsdk.android.touch.TempleAction.TripleClick
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import pw.binom.viewmate.core.MediaCommand
/**
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
*/
class AssistantGesturesTest {
private val movie = AssistantState()
private val listening = AssistantState(listening = true)
private val thinking = AssistantState(thinking = true)
private val answer = AssistantState(assistantText = "ответ")
// ————— режим по состоянию —————
@Test
fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
}
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
@Test
fun listeningWinsOverEverything() {
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
@Test
fun answerWinsOverThinking() {
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
}
@Test
fun thinkingOnlyWhenNoAnswer() {
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
}
// ————— кино ( NONE) —————
@Test
fun movieMatrix() {
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), movie))
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 30_000),
gestureDecision(slideForward(), movie),
)
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_BACKWARD, 30_000),
gestureDecision(slideBackwardAction(), movie),
)
// Свайп «плавного» типа без ответа не управляет скроллом → игнор.
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), movie))
}
@Test
fun movieSeekStepIsParameter() {
val d = gestureDecision(slideForward(), movie, seekStepMs = 10_000)
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
}
// ————— слушаю —————
@Test
fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
}
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
@Test
fun thinkingMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), thinking))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
}
// ————— «ответ виден» —————
@Test
fun answerMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
}
@Test
fun answerPageDeltaIsParameter() {
assertEquals(
GestureDecision.ScrollAnswer(80f),
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
)
}
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@Test
fun sessionTransitions() {
var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.copy(listening = true)
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
assertEquals(AssistantMode.THINKING, assistantMode(s))
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
s = s.copy(thinking = false, assistantText = "ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
// Выход: оверлей невидим, кино снова управляется кликом.
val exited = s.copy(assistantText = "")
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
@Test
fun cancelStaysInConversation() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.CancelDictation, decision)
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick.
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
}
// ————— оверлей —————
@Test
fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
assertFalse(overlayVisible(movie))
}
@Test
fun overlayVisibleInListeningThinkingAndAnswer() {
assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer))
}
@Test
fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking))
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
assertEquals(
"думаю...",
overlayHeader(AssistantState(listening = true, thinking = true)),
)
}
@Test
fun listeningHeaderFallsBackToListening() {
assertEquals("слушаю...", overlayHeader(listening))
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
}
@Test
fun answerHeaderIsLastPhraseOrNull() {
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
assertNull(overlayHeader(answer))
assertNull(overlayHeader(movie))
}
// ————— скролл ответа —————
@Test
fun scrollOffsetAppliesDelta() {
assertEquals(342, scrollOffset(132, 210f, 10_000))
}
@Test
fun scrollOffsetClampsAboveMax() {
assertEquals(1000, scrollOffset(800, 1000f, 1000))
}
@Test
fun scrollOffsetClampsBelowZero() {
assertEquals(0, scrollOffset(100, -500f, 1000))
}
/**
* Float-дельта округляется вниз (к целой части), а не к ближайшему.
* (10.7f в IEEE754 ~ 10.700000763, поэтому 100 - 10.7f = 89.2999… → 89.)
*/
@Test
fun scrollOffsetTruncatesFractionalDelta() {
assertEquals(112, scrollOffset(100, 12.7f, 10_000))
assertEquals(89, scrollOffset(100, -10.7f, 10_000))
}
@Test
fun scrollOffsetWithZeroMax() {
assertEquals(0, scrollOffset(5, 100f, 0))
}
/**
* SDK-жесты в юнит-тестах: MotionEvent создаётся через sun.misc.Unsafe
* (allocateInstance, без конструктора — в JVM-тестах Android-классы «not mocked»).
* Как в GestureCommandsTest.
*/
private fun fakeMotionEvent(): MotionEvent {
val unsafeClass = Class.forName("sun.misc.Unsafe")
val theUnsafe = unsafeClass.getDeclaredField("theUnsafe")
theUnsafe.isAccessible = true
val unsafe = theUnsafe.get(null)
val allocate = unsafeClass.getMethod("allocateInstance", Class::class.java)
return allocate.invoke(unsafe, MotionEvent::class.java) as MotionEvent
}
private fun unsafeFlingArgs(): FlingArgs {
val me1 = fakeMotionEvent()
val me2 = fakeMotionEvent()
return FlingArgs(me1, me2, 1f, 1f)
}
private fun slideForward() = SlideForward(unsafeFlingArgs())
private fun slideBackwardAction() = SlideBackward(unsafeFlingArgs())
/** SlideContinuous: дельта шлейфа; second — флаг long-жеста (на тест не влияет). */
private fun slideContinuous(delta: Float = 9f) = SlideContinuous(delta, false)
}
@@ -0,0 +1,190 @@
package pw.binom.viewmate.glasses.stt
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* Регрессия 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)")
}
/*
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
* gainChunk ×5 поднимает речь для whisper; VAD детектит на сыром сигнале.
*/
@Test
fun quietChunk_gained3_peakReachesAtLeast2700_sizeKept() {
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
val out = gainChunk(chunk)
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
val peak = s16Samples(out).maxOf { abs(it) }
assertTrue(peak >= 2_700, "тихий пик 1000 × 3 должен дать ≥ 2700, а не $peak")
}
@Test
fun loudChunk_gained3_clampedInsideS16Range() {
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
val out = gainChunk(chunk)
val s = s16Samples(out)
assertEquals(32_767, s.max(), "30000 × 3 = 90000 → насыщение до 32767")
assertEquals(-32_768, s.min(), "-30000 × 3 = -90000 → насыщение до -32768")
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
}
@Test
fun zeroChunk_gained3_bytesUnchanged() {
val chunk = ByteArray(CHUNK_BYTES)
val out = gainChunk(chunk)
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
assertTrue(out.all { it == 0.toByte() }, "нули остаются нулями после усиления")
}
@Test
fun gainEqualsOne_returnsSameInstance_noReallocation() {
val chunk = s16leChunk(1_600) { j -> (j % 3_001) - 1_500 }
assertSame(chunk, gainChunk(chunk, 1f), "gain = 1: не аллоцируем заново — возвратим сами bytes")
}
@Test
fun orderPreserved_signKept_firstTwoSamplesScaled() {
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
val s = s16Samples(gainChunk(chunk))
assertEquals(-369, s[0], "первый сэмпл: знак сохранён и усилен ×3")
assertEquals(1_368, s[1], "второй сэмпл: порядок сохранён и усилен ×3")
}
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
private fun s16leChunk(n: Int, make: (Int) -> Int): ByteArray {
val out = ByteArray(n * 2)
for (j in 0 until n) {
val v = make(j)
out[j * 2] = (v and 0xff).toByte()
out[j * 2 + 1] = ((v shr 8) and 0xff).toByte()
}
return out
}
/** s16le ByteArray → IntArray сэмплов (разбор как в s16leToFloat app-phone). */
private fun s16Samples(bytes: ByteArray): IntArray {
val n = bytes.size / 2
val out = IntArray(n)
for (j in 0 until n) {
val lo = bytes[j * 2].toInt() and 0xff
val hi = bytes[j * 2 + 1].toInt() and 0xff
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
out[j] = s
}
return out
}
private fun recorderWouldReturn(frames: Int) = frames
/*
* VAD на очках (схема pair-view): фреймы 20 мс (320 сэмплов @ 16 кГц),
* усиление ×10 перед VAD и whisper, склейка речевых сегментов в s16le.
*/
@Test
fun gainShort_quietSamples_boosted5_saturated() {
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
val out = gainShort(samples, 5f)
assertEquals(samples.size, out.size)
assertEquals(5_000, out[0].toInt(), "1000 × 5")
assertEquals(-5_000, out[1].toInt(), "-1000 × 5")
assertEquals(32_767, out[2].toInt(), "20000 × 5 → насыщение")
assertEquals(-32_768, out[3].toInt(), "-32768 × 5 → насыщение")
assertEquals(0, out[4].toInt())
}
@Test
fun gainShort_gain1_returnsSameArray() {
val samples = shortArrayOf(1, 2, 3)
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
}
@Test
fun downsample48To16_thirdLengthAndFIR() {
// 9 сэмплов 48 кГц (3 фрейма по 3) → 3 сэмпла 16 кГц
val frames = listOf(shortArrayOf(3, 6, 9), shortArrayOf(12, 15, 18), shortArrayOf(21, 24, 27))
val out = downsample48To16(frames)
assertEquals(1, out.size)
assertEquals(3, out[0].size, "длина = 1/3")
// тройки (3,6,9) → 6; (12,15,18) → 15; (21,24,27) → 24
assertEquals(6, out[0][0].toInt())
assertEquals(15, out[0][1].toInt())
assertEquals(24, out[0][2].toInt())
}
@Test
fun downsample48To16_shortSegment_keepsFloor() {
// 5 сэмплов → 1 (5/3 = 1)
val out = downsample48To16(listOf(shortArrayOf(1, 2, 3, 4, 5)))
assertEquals(1, out[0].size)
assertEquals(2, out[0][0].toInt(), "среднее (1,2,3)")
}
@Test
fun shortsToPcm16_concatenatesLittleEndian() {
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
val out = shortsToPcm16(frames)
assertEquals(6, out.size, "3 сэмпла × 2 байта")
assertEquals(1, out[0].toInt() and 0xff)
assertEquals(0, out[1].toInt())
assertEquals(2, out[2].toInt() and 0xff)
assertEquals(0, out[3].toInt())
assertEquals(3, out[4].toInt() and 0xff)
assertEquals(0, out[5].toInt())
}
@Test
fun shortsToPcm16_negativeSample_twoBytesComplement() {
val out = shortsToPcm16(listOf(shortArrayOf(-1)))
assertEquals(0xff, out[0].toInt() and 0xff)
assertEquals(0xff, out[1].toInt() and 0xff)
}
@Test
fun shortsToPcm16_empty_returnsEmpty() {
assertEquals(0, shortsToPcm16(emptyList()).size)
}
}
@@ -0,0 +1,74 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
/**
* Регрессия: размеры в WAV-заголовке должны быть little-endian — из-за big-endian
* (RandomAccessFile.writeInt) плееры не открывали дампы stt_dump_*.wav:
* для файла 188844 байта байты 4..8 были 00 02 e1 a4 вместо a4 e1 02 00.
*/
class WavWriterTest {
@Test
fun close_3200Data_riffAndDataSizesLittleEndian() {
val bytes = writeAndClose(3_200)
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
"RIFF size = 36 + data, little-endian (байты 4..8) — не big-endian из writeInt")
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44),
"data size = 3200, little-endian (байты 40..44)")
assertEquals(1_600, leIntAt(bytes, 40) / 2,
"nframes = data/2 (16 кГц s16le mono) — как считает python wave")
}
@Test
fun close_noData_riffSize36DataZero() {
val bytes = writeAndClose(0)
assertContentEquals(leIntBytes(36), bytes.copyOfRange(4, 8), "пустой data: RIFF size = 36")
assertEquals(0, leIntAt(bytes, 40), "пустой data: data size = 0")
}
@Test
fun closeTwice_sizesUnchanged() {
val file = tmpFile()
val w = WavWriter(file)
w.write(ByteArray(3_200))
w.close()
w.close()
val bytes = file.readBytes()
assertEquals(44 + 3_200, bytes.size, "второй close ничего не дописывает в файл")
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
"двойной close не портит размеры (флаг closed)")
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44))
}
private fun writeAndClose(dataBytes: Int): ByteArray {
val file = tmpFile()
val w = WavWriter(file)
if (dataBytes > 0) w.write(ByteArray(dataBytes))
w.close()
assertEquals(44L + dataBytes, file.length(), "файл = 44-байтный заголовок + data")
return file.readBytes()
}
private fun tmpFile(): File {
val f = Files.createTempFile("wvwriter-test", ".wav").toFile()
f.deleteOnExit()
return f
}
/** 4 байта little-endian — ожидаемый вид записи в WAV-заголовке. */
private fun leIntBytes(v: Int): ByteArray =
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array()
/** Прочитать 4 байта little-endian с позиции offset (то, как rидер получит int32). */
private fun leIntAt(bytes: ByteArray, offset: Int): Int {
fun b(i: Int) = bytes[i].toInt() and 0xff
return b(offset) or (b(offset + 1) shl 8) or (b(offset + 2) shl 16) or (b(offset + 3) shl 24)
}
}
+8
View File
@@ -94,10 +94,16 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core")) implementation(project(":lib-core"))
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый) // Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
implementation(libs.koog.openai.client) implementation(libs.koog.openai.client)
implementation(libs.koog.http.client.okhttp) implementation(libs.koog.http.client.okhttp)
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU)
implementation(libs.litertlm.android)
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack // Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6") implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
@@ -108,6 +114,8 @@ dependencies {
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0") implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
testImplementation(kotlin("test")) 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-server-test-host:3.3.0")
testImplementation("io.ktor:ktor-client-websockets:3.3.0") testImplementation("io.ktor:ktor-client-websockets:3.3.0")
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0") testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
+28
View File
@@ -0,0 +1,28 @@
---
name: media
description: поиск фильмов и сериалов, детали, запуск и управление просмотром на очках.
---
Скилл: медиа (Jellyfin-каталог, просмотр на очках).
Шаги:
1. Для работы с медиа сначала активируй набор: TOOL_CALL: enable_toolset|media
— дождись ответа: в нём реестр тул (media_search, media_info, media_play) и пометка,
что сам поиск ещё НЕ выполнялся.
2. Поиск: TOOL_CALL: media_search|<название> [limit=<кол-во, по умолчанию 5>]
— результат: строки «id: Название (Год) — Тип». Покажи пользователю найденное
и спроси, что включить (или включи прямо, если выбор ясный).
3. Детали: TOOL_CALL: media_info|<id> — название, тип, год, описание, длительность, постер.
4. Запуск: TOOL_CALL: media_play|<id> [seek_ms=<позиция в мс>]
— видео должно быть скачано на очки, звук — на телефон (это проверка исполнителя,
а не твоя: если не готово — в ответе будет точная причина и команда заказа).
Питфоллы:
- media_* вызывается ТОЛЬКО когда тулсет активен: если набор не активен, сначала
enable_toolset|media — иначе система подменит вызов на активацию и скажет,
что поиск ещё не выполнялся.
- enable_toolset|media повторять не нужно: повторный вызов — продление таймаута,
не ошибка. Таймаут активности 10 минут с последнего вызова.
- Если media_play вернул «видео не скачано / звук не готов / очки не подключены»
— сообщи пользователю как есть (это фактическое состояние, не сбой нейросети)
и предложи дождаться или скачать вручную.
- Ответы тул фактические: не додумывай фильмов, которых нет в результате media_search.
+27 -1
View File
@@ -7,7 +7,18 @@
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" /> android:usesPermissionFlags="neverForLocation" />
<!-- Bluetooth SPP-транспорт (включается флагом PhoneConfig.USE_BLUETOOTH):
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".PhoneApp" android:name=".PhoneApp"
@@ -16,13 +27,28 @@
android:allowBackup="true" android:allowBackup="true"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<!-- NOTE 2026-08-23 (porfiry): точь-в-точь как у автора — ВНУТРИ <application>
(AndroidManifest.xml автора, строки 59-61: три uses-native-library).
Системные libOpenCL.so (Adreno) + libcdsprpc.so (Qualcomm-расчёты)
видны из app-namespace; без них LiteRT-LM падает на
"Can not find OpenCL library on this device". -->
<uses-native-library android:name="libvndksupport.so" android:required="false"/>
<uses-native-library android:name="libOpenCL.so" android:required="false"/>
<uses-native-library android:name="libcdsprpc.so" android:required="false"/>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,200 @@
package pw.binom.viewmate.phone
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.net.BtTransportConstants
import pw.binom.viewmate.core.net.GlassesServerTransport
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Bluetooth-транспорт, сторона телефона («сервер») — TASK-transport.md п.3:
* RFCOMM/SPP ([BtTransportConstants.SPP_UUID]), сообщения — строки
* (JSON + перевод строки как разделитель), read-поток — на Dispatchers.IO,
* запись — под синхронизацией на сокете. [connId] = MAC-адрес очков.
* Работает флагом [pw.binom.viewmate.phone.PhoneConfig.USE_BLUETOOTH]
* (дефолт — [WifiServerTransport]).
* @param context — для runtime-проверки BT-пермишенов (Android 12+ / до 12).
*/
class BtServerTransport(
private val context: Context? = null,
) : GlassesServerTransport {
companion object {
/**
* Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
private fun hasBtPermissions(ctx: Context): Boolean =
runtimePermissions().all {
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
}
}
override val name = "bluetooth"
@Volatile
private var closed = false
@Volatile
private var serverSocket: BluetoothServerSocket? = null
private val sockets = ConcurrentHashMap<String, BluetoothSocket>()
private val writers = ConcurrentHashMap<String, OutputStream>()
private val readerJobs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
private val readerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Бесконечно принимать SPP-соединения очков; без BT/пермишена — ретраи через 3с. */
override suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
while (!closed) {
if (serverSocket == null) {
serverSocket = openServerSocket()
if (serverSocket == null) {
log("bt", "SPP-сервер не создан (BT выключен? нет пермишена?), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
delay(BtTransportConstants.RETRY_DELAY_MS)
continue
}
log("bt", "SPP-сервер запущен: ${BtTransportConstants.SERVICE_NAME} (${BtTransportConstants.SPP_UUID})")
}
try {
val s = withContext(Dispatchers.IO) { serverSocket!!.accept() } ?: break
if (closed) {
runCatching { s.close() }
break
}
val remote: BluetoothDevice = s.remoteDevice
val connId = remote.address
sockets[connId] = s
writers[connId] = s.outputStream
log("bt", "очки подключились: $connId")
onConnected(connId)
readerJobs[connId] = readerScope.launch {
readLines(s, connId, onMessage, onDisconnected)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "accept прерван (${e::class.simpleName}: ${e.message}), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
runCatching { serverSocket?.close() }
serverSocket = null
delay(BtTransportConstants.RETRY_DELAY_MS)
}
}
cleanupConnections()
readerScope.cancel()
}
/** SPP-сервер (RFCOMM-канал SPP): без BT-радио/пермишена — null (лечь и ретраиться). */
@SuppressLint("MissingPermission")
private fun openServerSocket(): BluetoothServerSocket? {
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
log("bt", "BT-радио не найдено — SPP недоступен")
return null
}
if (!adapter.isEnabled) {
log("bt", "Bluetooth выключен — включите в настройках Android")
return null
}
val ctx = context
if (ctx != null && !hasBtPermissions(ctx)) {
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
return null
}
return runCatching {
adapter.listenUsingRfcommWithServiceRecord(
BtTransportConstants.SERVICE_NAME,
UUID.fromString(BtTransportConstants.SPP_UUID),
)
}.onFailure { log("bt", "не удалось создать SPP-сервер: ${it.message}") }.getOrNull()
}
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
private suspend fun readLines(
s: BluetoothSocket,
connId: String,
onMessage: suspend (connId: String, json: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
val reader = BufferedReader(InputStreamReader(s.inputStream))
try {
while (!closed) {
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
if (line.isNotBlank()) onMessage(connId, line)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "read-поток $connId оборван: ${e.message}")
} finally {
runCatching { reader.close() }
runCatching { s.close() }
sockets.remove(connId)
writers.remove(connId)
readerJobs.remove(connId)
}
if (!closed) {
log("bt", "очки отключились: $connId")
onDisconnected(connId)
}
}
/** JSON-строка в сокет [connId]: UTF-8 + разделитель, под синхронизацией на сокете. */
override suspend fun send(connId: String, json: String) {
val out = writers[connId] ?: return
runCatching {
synchronized(out) {
out.write(json.toByteArray(Charsets.UTF_8))
out.write(BtTransportConstants.NEWLINE.code)
out.flush()
}
}.onFailure { log("bt", "запись на $connId не удалась: ${it.message}") }
}
override fun close() {
closed = true
runCatching { serverSocket?.close() }
serverSocket = null
cleanupConnections()
readerScope.cancel()
}
private fun cleanupConnections() {
readerJobs.values.forEach { it.cancel() }
readerJobs.clear()
sockets.values.forEach { runCatching { it.close() } }
sockets.clear()
writers.clear()
}
}
@@ -0,0 +1,187 @@
package pw.binom.viewmate.phone
import com.google.ai.edge.litertlm.Backend
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.LocalLlmClient
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.InetAddress
import java.net.ServerSocket
/**
* Отладочный HTTP-сервер в приложении (только для разработки).
* Позволяет дёргать ассистента и переключать модель без UI:
*
* GET /status — выбор модели, готовность движка, размер файла
* POST /chat {"text":"привет"} — отправить фразу в ассистента (та же очередь, что UI)
* POST /model {"choice":"local"} — переключить модель (local|remote), персист
*
* Запускается на 127.0.0.1:8787. Доступ: `adb forward tcp:8787 tcp:8787`.
*/
object DebugHttpServer {
private const val PORT = 8787
private const val TAG = "httpd"
@Volatile
private var running = false
fun start(app: PhoneApp) {
if (running) return
running = true
Thread {
try {
val server = ServerSocket(PORT, 8, InetAddress.getByName("127.0.0.1"))
log(TAG, "debug-http на 127.0.0.1:$PORT (adb forward tcp:8787 tcp:8787)")
while (running) {
try {
val socket = server.accept()
Thread {
try {
handle(socket, app)
} catch (e: Exception) {
log(TAG, "обработка запроса: ${e.message}")
} finally {
runCatching { socket.close() }
}
}.apply { isDaemon = true }.start()
} catch (e: Exception) {
if (running) log(TAG, "accept: ${e.message}")
}
}
} catch (e: Exception) {
log(TAG, "не удалось запустить: ${e.message}")
}
}.apply { isDaemon = true; name = "viewmate-httpd" }.start()
}
private fun handle(socket: java.net.Socket, app: PhoneApp) {
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val requestLine = reader.readLine() ?: return
val parts = requestLine.split(" ")
if (parts.size < 2) return
val method = parts[0]
val rawPath = parts[1]
val path = rawPath.substringBefore("?")
val queryParams = rawPath.substringAfter("?", "").split("&")
.filter { it.isNotEmpty() }
.associate { it.substringBefore("=") to it.substringAfter("=") }
// Тело (до Content-Length)
var contentLength = 0
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break
if (line.startsWith("Content-Length:", true)) {
contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0
}
}
val body = if (contentLength > 0) {
val buf = CharArray(contentLength)
var read = 0
while (read < contentLength) {
val n = reader.read(buf, read, contentLength - read)
if (n < 0) break
read += n
}
String(buf, 0, read)
} else ""
val response: String
val code: Int
when {
method == "GET" && path.startsWith("/status") -> {
val choice = app.llmChoice.value
val local = app.localLlmClient()
val file = app.localModelFile()
response = """{"choice":"${choice.name.lowercase()}","local":{${localStatus(local, file)}}}"""
code = 200
}
method == "POST" && path.startsWith("/chat") -> {
val text = jsonField(body, "text") ?: ""
if (text.isBlank()) {
response = """{"error":"пустой text"}"""
code = 400
} else {
app.sendToAssistant(text)
response = """{"ok":true,"queued":"${text.take(60)}"}"""
code = 200
}
}
method == "POST" && path.startsWith("/model") -> {
val choice = jsonField(body, "choice")
if (choice == "local" || choice == "remote") {
app.setLlmModelChoice(if (choice == "local") LlmModelChoice.LOCAL else LlmModelChoice.REMOTE)
response = """{"ok":true,"choice":"$choice"}"""
code = 200
} else {
response = """{"error":"choice должен быть local|remote"}"""
code = 400
}
}
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
method == "GET" && path.startsWith("/mercury/status") -> {
val s = MercuryBridge.state.value
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/connect") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/disconnect") -> {
kotlinx.coroutines.runBlocking { MercuryBridge.disconnect() }
response = """{"ok":true}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/screen/off") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOff() }
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/screen/on") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOn() }
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/autolock") -> {
val time = queryParams["time"]?.toIntOrNull() ?: 10
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.autoLockTime(time) }
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
else -> {
response = """{"error":"нет такого пути: $method $path"}"""
code = 404
}
}
val out = socket.getOutputStream()
val payload = response.toByteArray(Charsets.UTF_8)
out.write(("HTTP/1.1 $code ${reason(code)}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8))
out.write(payload)
out.flush()
}
private fun localStatus(local: LocalLlmClient?, file: java.io.File): String {
if (local == null) return """"ready":false,"fileExists":${file.exists()},"fileBytes":${file.length()}"""
return """"ready":true,"modelDownloaded":${local.modelDownloaded()},"fileExists":${file.exists()},"fileBytes":${file.length()}"""
}
private fun jsonField(body: String, key: String): String? {
// {"text":"..."} — примитивный парсер, без кавычек внутри значения
val regex = Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"")
return regex.find(body)?.groupValues?.get(1)
}
private fun jsonEscape(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
private fun reason(code: Int): String = when (code) {
200 -> "OK"
400 -> "Bad Request"
404 -> "Not Found"
else -> "Error"
}
}
@@ -1,23 +1,18 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
import io.ktor.server.application.Application import android.content.Context
import io.ktor.server.application.install import kotlinx.coroutines.CoroutineScope
import io.ktor.server.cio.CIO import kotlinx.coroutines.Dispatchers
import io.ktor.server.engine.EmbeddedServer import kotlinx.coroutines.Job
import io.ktor.server.engine.embeddedServer import kotlinx.coroutines.SupervisorJob
import io.ktor.server.routing.routing import kotlinx.coroutines.cancel
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.net.GlassesServerTransport
import pw.binom.viewmate.core.phone.GlassesSender import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.DownloadProgress import pw.binom.viewmate.core.protocol.DownloadProgress
@@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
/** /**
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap). * Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие * Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза, * не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
* DownloadProgress → карта скачивания на очки). * SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
*/ */
class GlassesHub( class GlassesHub(
private val state: PhoneState, private val state: PhoneState,
/** Выбранный транспорт (дефолт — [WifiServerTransport]). */
private val transport: GlassesServerTransport,
) { ) {
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>() /** Сессии: connId → Unit. */
private val sessions = ConcurrentHashMap<String, Unit>()
private val _connected = MutableStateFlow(0) private val _connected = MutableStateFlow(0)
val connected: StateFlow<Int> = _connected.asStateFlow() val connected: StateFlow<Int> = _connected.asStateFlow()
@@ -100,42 +99,47 @@ class GlassesHub(
/** /**
* Стриминговое распознавание речи с очков. Лениво создаётся через * Стриминговое распознавание речи с очков. Лениво создаётся через
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s * [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
* рассылаются через [broadcast]. * рассылаются через [broadcast].
*/ */
@Volatile @Volatile
var stt: SttStreamer? = null var stt: SttStreamer? = null
/**
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
* на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
* отсутствуют — только полный текст (по клику или по тишине 30 с).
*/
@Volatile
var onStopFullText: ((String) -> Unit)? = null
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */ /** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
@Volatile @Volatile
var sttFactory: (() -> SttStreamer?)? = null var sttFactory: (() -> SttStreamer?)? = null
fun add(session: DefaultWebSocketServerSession) { fun add(connId: String) {
sessions[session] = Unit sessions[connId] = Unit
_connected.value = sessions.size _connected.value = sessions.size
log("hub", "очки подключились, всего: ${sessions.size}") log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
} }
fun remove(session: DefaultWebSocketServerSession) { fun remove(connId: String) {
sessions.remove(session) sessions.remove(connId)
_connected.value = sessions.size _connected.value = sessions.size
log("hub", "очки отключились, всего: ${sessions.size}") log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
} }
/** Отправить сообщение всем подключённым очкам. */ /** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
suspend fun broadcast(msg: HostToGlasses) { suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg) val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
for (session in sessions.keys) { for (connId in sessions.keys) {
try { runCatching { transport.send(connId, text) }
session.send(text) .onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
} catch (e: Exception) {
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
}
} }
} }
/** Обработать входящее текстовое сообщение от очков. */ /** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) { suspend fun handle(connId: String, text: String) {
val msg = try { val msg = try {
protocolJson.decodeFromString(GlassesToHost.serializer(), text) protocolJson.decodeFromString(GlassesToHost.serializer(), text)
} catch (e: Exception) { } catch (e: Exception) {
@@ -144,8 +148,8 @@ class GlassesHub(
} }
when (msg) { when (msg) {
is Hello -> { is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome") log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId)) send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
} }
is Gesture -> { is Gesture -> {
@@ -199,80 +203,76 @@ class GlassesHub(
is StopStt -> { is StopStt -> {
if (msg.cancel) { if (msg.cancel) {
log("stt", "распознавание отменено (очки)") log("stt", "распознавание отменено (очки)")
send(session, SttCancel()) send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
stt?.reset() stt?.reset()
} else { } else {
val full = stt?.finish() ?: "" val full = stt?.finish() ?: ""
log("stt", "ВЕСЬ ТЕКСТ: $full") log("stt", "ВЕСЬ ТЕКСТ: $full")
send(session, SttDone(full))
stt?.reset() 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))
}
} }
} }
} }
} }
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) { private suspend fun send(connId: String, msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg) val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
session.send(text) runCatching { transport.send(connId, text) }
} .onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
}
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
fun Application.glassesServerModule(hub: GlassesHub) {
install(WebSockets) {
pingPeriodMillis = 5_000
timeoutMillis = 5_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(GLASSES_WS_PATH) {
hub.add(this)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
hub.handle(this, text)
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
hub.remove(this)
}
}
} }
} }
/** /**
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty). * Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses. * [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
* (SPP). Поведение по умолчанию (WiFi) не меняется.
*/ */
class GlassesServer( class GlassesServer(
private val port: Int = PhoneConfig.SERVER_PORT, private val port: Int = PhoneConfig.SERVER_PORT,
private val state: PhoneState = PhoneState(), private val state: PhoneState = PhoneState(),
/** `false` (дефолт) — WiFi; `true` — Bluetooth SPP. */
useBluetooth: Boolean = false,
/** Context для runtime-проверки BT-пермишенов (нужен только в [BtServerTransport]). */
private val btContext: Context? = null,
) { ) {
val hub = GlassesHub(state) /** Активный транспорт: [WifiServerTransport] (дефолт) или [BtServerTransport]. */
val transport: GlassesServerTransport =
if (useBluetooth) BtServerTransport(btContext) else WifiServerTransport(port)
val hub = GlassesHub(state, transport)
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */ /** GlassesSender-адаптер поверх хаба (для PhoneActions). */
val sender: GlassesSender = object : GlassesSender { val sender: GlassesSender = object : GlassesSender {
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg) override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
} }
private var server: EmbeddedServer<*, *>? = null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var acceptJob: Job? = null
/** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */
fun start() { fun start() {
if (server != null) return if (acceptJob != null) return
server = embeddedServer(CIO, port = port, host = "0.0.0.0") { acceptJob = scope.launch {
glassesServerModule(hub) transport.accept(
onMessage = { connId, json -> hub.handle(connId, json) },
onConnected = { connId -> hub.add(connId) },
onDisconnected = { connId -> hub.remove(connId) },
)
} }
server!!.start(wait = false)
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
} }
fun stop() { fun stop() {
server?.stop(1_000, 3_000) transport.close()
server = null scope.cancel()
} }
} }
@@ -1,6 +1,18 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
/** Единый лог телефона: простой println (работает и в unit-тестах). */ import android.util.Log
/**
* Единый лог телефона.
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
* (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
* чтобы не падать.
*/
fun log(tag: String, message: String) { fun log(tag: String, message: String) {
println("[view-mate] $tag $message") val full = "[view-mate] $tag $message"
runCatching {
Log.d("view-mate-$tag", full)
}.onFailure {
println(full)
}
} }
@@ -1,5 +1,7 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.view.Gravity import android.view.Gravity
@@ -7,6 +9,7 @@ import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.core.content.ContextCompat
import pw.binom.viewmate.phone.stt.SttDebug import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.agent.LlmDebug import pw.binom.viewmate.phone.agent.LlmDebug
import pw.binom.viewmate.phone.ui.ViewMateApp import pw.binom.viewmate.phone.ui.ViewMateApp
@@ -15,6 +18,7 @@ import java.io.File
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestBtPermissions()
setContent { setContent {
ViewMateApp() ViewMateApp()
} }
@@ -26,6 +30,23 @@ class MainActivity : ComponentActivity() {
} }
} }
/**
* Runtime-запрос Bluetooth-пермишенов (TASK-transport.md п.5): только при
* вкл. SPP ([PhoneConfig.USE_BLUETOOTH]). На 31+ — BLUETOOTH_CONNECT/SCAN,
* на ≤30 — BLUETOOTH/BLUETOOTH_ADMIN. Если уже выданы — молча пропускаем.
*/
private fun requestBtPermissions() {
if (!PhoneConfig.USE_BLUETOOTH) return
val needed = BtServerTransport.runtimePermissions()
val missing = needed.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
requestPermissions(missing.toTypedArray(), 1009)
log("bt", "запрошены Bluetooth-пермишены (SPP): ${missing.joinToString()}")
}
}
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */ /** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
private fun runLlmDebug() { private fun runLlmDebug() {
showOverlay("[llm] вызываю llm.binom.pw...") showOverlay("[llm] вызываю llm.binom.pw...")
@@ -0,0 +1,198 @@
package pw.binom.viewmate.phone
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury
/**
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
*
* Минимум для полевого теста: скан → pair → connect → погасить/включить экран
* (deepSuspend). События либы логируются и частично проксируются в [status].
*
* Кнопки экрана — только проверка транспорта: если очки принимают команду,
* протокол жив, дальше можно вешать остальные команды (яркость, WiFi, магазин).
*/
object MercuryBridge {
private const val TAG = "MercuryBridge"
private var mercury: RayNeoMercury? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
data class UiState(
val connected: Boolean = false,
val glassesName: String? = null,
val statusText: String = "не подключено",
val lastError: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
private var currentGlasses: DiscoveredGlasses? = null
/** Инициализация (вызывать из Application). Стартует GATT-сервер: очки сами нас найдут. */
fun init(context: Context) {
if (mercury != null) return
mercury = RayNeoMercury(context.applicationContext, scope)
scope.launch {
mercury!!.events.collect { event ->
when (event) {
is RayNeoMercury.Event.Connected -> {
_state.value = _state.value.copy(connected = true)
setStatus("подключено (BLE)")
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
// при гашении экрана очки рвут BLE-связь через ~3 секунды.
// Повторяем, пока очки не будут готовы (они переподключаются
// в первые секунды и первое сообщение могут потерять).
scope.launch {
for (i in 0 until 30) {
if (!_state.value.connected) break
val ok = mercury?.enableHeartbeat() ?: false
if (i % 3 == 0) log("hb", "enable_heartbeat #$i ok=$ok")
delay(3000)
}
}
}
is RayNeoMercury.Event.Disconnected -> {
_state.value = _state.value.copy(connected = false)
setStatus("отключено: ${event.cause}")
}
is RayNeoMercury.Event.JsonMessage -> {
log("json", "${event.uri} ${event.json.take(200)}")
// Очки шлют ble.request.* и ждут ble.response.* с тем же id.
// Без ответа они рвут соединение — отвечаем эхом cmd/payload.
val uri = event.uri
val id = event.id
if (uri != null && id != null && uri.startsWith("ble.request.") &&
uri != "ble.request.general.status" && uri != "ble.request.general.settings"
) {
val respUri = uri.replace("ble.request.", "ble.response.")
val payload = parsePayload(event.json)
scope.launch {
mercury?.send(pw.binom.mercury.model.PayloadMessage(respUri, id, payload))
log("resp", "answered $uri -> $respUri")
}
}
}
is RayNeoMercury.Event.StreamPacket -> log("stream", "packet seq=${event.seq} type=${event.type} ${event.payload.size}B")
is RayNeoMercury.Event.AppInstallStatus -> setStatus("app: ${event.pkg} status=${event.status} err=${event.errCode}")
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}")
is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}")
}
}
}
val started = mercury!!.startServer()
log("server", if (started) "GATT-сервер + реклама запущены" else "GATT-сервер НЕ стартовал")
setStatus(if (started) "рекламирую сервис… жду очки" else "ошибка старта GATT-сервера")
}
/** Прав на BLE нет — вернуть false; хост-приложение запрашивает само. */
fun hasBlePermissions(context: Context): Boolean {
val m = mercury ?: return false
return m.hasPermissions() &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
}
/**
* Подключение: гарантирует, что GATT-сервер рекламируется, и ждёт, пока
* очки сами подключатся к нам (они действуют как GATT-клиент). Если очки
* всё же рекламируют — дополнительно пробуем скан-путь (клиент).
*/
suspend fun connectFirst(timeoutMillis: Long = 25_000): Boolean {
val m = mercury ?: return false
if (!m.isServerRunning && !m.startServer()) {
setError("не удалось запустить GATT-сервер")
return false
}
setStatus("рекламирую сервис… жду очки")
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() < deadline) {
if (m.isAnyConnected) {
_state.value = _state.value.copy(connected = true)
setStatus("подключено (BLE)")
return true
}
// На случай, если очки рекламируются сами — клиентский путь.
val glasses = m.scanOnce(3000)
if (glasses != null) {
setStatus("найдены: ${glasses.name ?: glasses.address}, подключаюсь…")
currentGlasses = glasses
if (m.connect(glasses, 8000)) {
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
setStatus("подключено (клиент)")
return true
}
}
}
setError("очки не подключились за ${timeoutMillis / 1000} с")
return false
}
suspend fun disconnect() {
mercury?.disconnect()
_state.value = _state.value.copy(connected = false)
setStatus("отключено")
}
/** Погасить экран очков (deepSuspend=1). */
suspend fun screenOff(): Boolean {
val ok = mercury?.setDeepSuspend(true) ?: false
setStatus(if (ok) "команда «погасить экран» отправлена" else "ошибка отправки (нет связи?)")
return ok
}
/** Включить экран очков (deepSuspend=0). */
suspend fun screenOn(): Boolean {
val ok = mercury?.setDeepSuspend(false) ?: false
setStatus(if (ok) "команда «включить экран» отправлена" else "ошибка отправки (нет связи?)")
return ok
}
/** Автоблокировка экрана через N секунд; -1 = никогда. */
suspend fun autoLockTime(time: Int): Boolean {
val ok = mercury?.setAutoLockTime(time) ?: false
setStatus(if (ok) "auto_lock_time=$time отправлен" else "ошибка отправки (нет связи?)")
return ok
}
private fun setStatus(text: String) {
log("status", text)
_state.value = _state.value.copy(statusText = text, lastError = null)
}
private fun setError(text: String) {
Log.e(TAG, text)
_state.value = _state.value.copy(statusText = text, lastError = text)
}
private fun parsePayload(json: String): pw.binom.mercury.model.Payload? = try {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(json).jsonObject
val el = obj["payload"] ?: return null
kotlinx.serialization.json.Json.decodeFromJsonElement(pw.binom.mercury.model.Payload.serializer(), el)
} catch (_: Exception) {
null
}
private fun log(tag: String, msg: String) {
Log.d(TAG, "[$tag] $msg")
}
}
@@ -5,7 +5,9 @@ import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -21,13 +23,29 @@ import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.AssistantState import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg 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.ShowText
import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.phone.agent.AgentToolkit
import pw.binom.viewmate.phone.agent.Assistant import pw.binom.viewmate.phone.agent.Assistant
import pw.binom.viewmate.phone.agent.ChatDao import pw.binom.viewmate.phone.agent.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb import pw.binom.viewmate.phone.agent.ChatDb
import pw.binom.viewmate.phone.agent.LlmClient 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
import pw.binom.viewmate.phone.agent.LocalLlmClient
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.LlmPrefs
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
import pw.binom.viewmate.phone.agent.MediaToolExecutor
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.SttStreamer import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt import pw.binom.viewmate.phone.stt.WhisperStt
@@ -39,8 +57,13 @@ class PhoneApp : Application() {
val state: PhoneState = PhoneState() val state: PhoneState = PhoneState()
/** WS-сервер для очков. */ /** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state) val server: GlassesServer = GlassesServer(
port = PhoneConfig.SERVER_PORT,
state = state,
useBluetooth = PhoneConfig.USE_BLUETOOTH,
btContext = this,
)
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */ /** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
val nsd: NsdPublisher by lazy { NsdPublisher(this) } val nsd: NsdPublisher by lazy { NsdPublisher(this) }
@@ -85,16 +108,165 @@ class PhoneApp : Application() {
private var assistantLock = Any() private var assistantLock = Any()
private var assistant: Assistant? = null private var assistant: Assistant? = null
private var toolkit: AgentToolkit? = null
/** Модель, на которой создан текущий [assistant] — смена выбора пересоздаёт ассистента. */
private var assistantChoice: LlmModelChoice? = null
/** Активный локальный LLM-клиент (движок закрываем при смене выбора). */
private var localLlm: LocalLlmClient? = null
/** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */ /** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */
fun localLlmClient(): LocalLlmClient? = localLlm
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
// lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
// getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
// тот же паттерн, что у chatDao/downloadManager (иначе NPE при старте).
val llmPrefs: LlmPrefs by lazy { LlmPrefs(this) }
private val _llmChoice: MutableStateFlow<LlmModelChoice> by lazy { MutableStateFlow(llmPrefs.current()) }
val llmChoice: StateFlow<LlmModelChoice> by lazy { _llmChoice.asStateFlow() }
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */
private val _localLlmState: MutableStateFlow<LocalLlmModelState> by lazy { MutableStateFlow(initialLocalLlmState()) }
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
private var localLlmDownloadJob: Job? = null
private fun initialLocalLlmState(): LocalLlmModelState =
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
/** Файл локальной модели: getExternalFilesDir(null)/gemma-4-E2B-it.litertlm. */
fun localModelFile(): File = File(getExternalFilesDir(null) ?: filesDir, Gemma4E2B.FILE_NAME)
/** Сменить выбор модели: персист + пересоздание ассистента при следующей фразе. */
fun setLlmModelChoice(choice: LlmModelChoice) {
if (_llmChoice.value == choice) return
llmPrefs.set(choice)
_llmChoice.value = choice
synchronized(assistantLock) { assistant = null }
log("llm", "выбрана модель: ${choice.name}")
}
/**
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
* Повторный вызов во время скачивания — no-op.
*/
fun downloadLocalLlmModel() {
val current = localLlmDownloadJob
if (current != null && current.isActive) return
localLlmDownloadJob = scope.launch {
val target = localModelFile()
_localLlmState.value = LocalLlmModelState.Downloading(0)
try {
GemmaModelDownloader(target).download { percent ->
_localLlmState.value = LocalLlmModelState.Downloading(percent)
}
_localLlmState.value = LocalLlmModelState.Ready
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
log("llm", "не удалось скачать модель: ${e.message}")
}
}
}
/** Остановить скачивание локальной модели (частичный файл удаляется). */
fun cancelLocalLlmDownload() {
localLlmDownloadJob?.cancel()
}
/**
* Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу.
* LLM-клиент берётся по текущему выбору [llmChoice]: серверный или локальный
* LiteRT-LM; при смене выбора ассистент создаётся заново.
*/
private fun ensureAssistant(): Assistant? { private fun ensureAssistant(): Assistant? {
synchronized(assistantLock) { synchronized(assistantLock) {
assistant?.let { return it } val choice = _llmChoice.value
if (BuildConfig.LLM_API_KEY.isBlank()) return null if (assistant != null) {
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL) if (assistantChoice == choice) return assistant
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) }) assistant = null
.also { assistant = it }
} }
if (choice == LlmModelChoice.REMOTE && BuildConfig.LLM_API_KEY.isBlank()) {
assistantChoice = choice
return null
}
runCatching { localLlm?.close() }
localLlm = null
val llm = when (choice) {
LlmModelChoice.REMOTE -> RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
LlmModelChoice.LOCAL -> {
// cacheDir — подкаталог cache/litertlm: в 11:46 именно с ним
// (без спекуляции) движок инициализировался за 12с; с корнем
// cache/ initialize() виснет. GPU-делегату нужен СВОЙ каталог.
val cacheDir = File(cacheDir, "litertlm")
LocalLlmClient(modelFile = localModelFile(), cacheDir = cacheDir).also { localLlm = it }
}
}
val toolsets = ToolsetRegistry()
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
val tk = toolkit ?: AgentToolkit(
skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()),
toolsets = toolsets,
showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } },
).also { toolkit = it }
return Assistant(
chatDao = chatDao,
chat = { history, system -> llm.chat(history, system) },
toolkit = tk,
// Локальной модели нужно время: инициализация движка ~17с на
// CPU + генерация. Серверная укладывается в 30с, локальной
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
timeoutMs = if (choice == LlmModelChoice.LOCAL) 300_000L else 30_000L,
).also {
assistant = it
assistantChoice = choice
}
}
}
/** Тулсет media: тулы работают через [JellyfinMediaSource] (каталог) и уровня-приложения [agentPlayMedia] («включить на очках»). */
private fun mediaToolExecutor(): MediaToolExecutor = MediaToolExecutor(
media = JellyfinMediaSource(jellyfin) { userId.value },
player = { id, seekMs -> agentPlayMedia(id, seekMs) },
)
/**
* Тул ассистента media_play: проверки (очки на связи, видео скачано на очки,
* звук скачан на телефон) + запуск через [PhoneActions.watch] (механизм DetailsScreen)
* + звук на телефоне выбранной дорожкой с позиции [seekMs]. Возвращает текст для модели.
*/
private suspend fun agentPlayMedia(id: String, seekMs: Long): String {
val hub = server.hub
if (hub.connected.value == 0) return "Очки не подключены."
val mediaDir = hub.mediaDir.value
if (mediaDir.isNullOrBlank()) return "Очки ещё не опубликовали медиакаталог — попробуйте позже."
if (id !in hub.downloadedItemIds.value) return "Видео не скачано на очки. Скачайте его вручную через телефон."
if (!downloadManager.audioReady(id)) return "Звук не готов: сначала скачайте аудио на телефон."
val acts = actions ?: return "Телефон ещё не готов."
val audioIndex = state.currentAudioIndex
log("agent", "play: $id, звук #$audioIndex, seek=${seekMs} мс")
val result = acts.watch(
itemId = id,
audioIndex = audioIndex,
localVideoPath = "file://$mediaDir/$id/video.mkv",
phoneAudioReady = true,
startPositionMs = seekMs,
)
if (!result.contains("включил")) return result
// Звук на телефоне (с позиции, как DetailsScreen): локальный ogg или S3-стрим.
if (audioSync.isActive) audioSync.stop()
val audio = resolveAudioSourceDetailed(
mirrorFiles = mirror.mirrorByItem(id)?.files,
audioIndex = audioIndex,
localAudioFile = { downloadManager.audioFile(id, it) },
)
if (audio.source == null) return "Видео включено, но источники звука отсутствуют: ${audio.reason}"
audioSync.play(audio.source, seekMs.coerceAtLeast(0L))
return "Включено: «${state.currentItem?.Name ?: id}»"
} }
/** /**
@@ -103,20 +275,73 @@ class PhoneApp : Application() {
*/ */
private suspend fun assistantLoop() { private suspend fun assistantLoop() {
for (phrase in assistantChannel) { for (phrase in assistantChannel) {
log("assistant", "получена фраза: $phrase")
val assistant = ensureAssistant() val assistant = ensureAssistant()
if (assistant == null) { if (assistant == null) {
log("assistant", "LLM-ключ не задан — фраза пропущена") log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
continue continue
} }
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING)) server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
val answer = runCatching { assistant.process(phrase) } val answer = runCatching {
val active = _activeSessionId.value
if (active != null) {
assistant.processInSession(active, phrase)
} else {
assistant.process(phrase)
}
}
.onFailure { log("assistant", "ошибка: ${it.message}") } .onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" } .getOrElse { "Ассистент недоступен: ${it.message}" }
server.hub.broadcast(ShowText(answer)) server.hub.broadcast(ShowText(answer))
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
server.hub.broadcast(chatHistoryMsg())
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE)) 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 сохраняется.
*/
fun sendToAssistant(text: String) {
scope.launch { assistantChannel.send(text) }
}
/** Активная сессия чата (для UI телефона). null — «последняя» (дефолт). */
private val _activeSessionId = MutableStateFlow<Long?>(null)
val activeSessionId: StateFlow<Long?> = _activeSessionId
/** Создать новую сессию и сделать её активной. */
fun newChatSession(): Long {
val id = chatDao.createSession()
_activeSessionId.value = id
return id
}
/** Переключить активную сессию. null — вернуться к «последней». */
fun selectChatSession(id: Long?) {
_activeSessionId.value = id
}
/** Сбросить (очистить сообщения) активной или последней сессии. */
fun resetChatSession() {
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: return
chatDao.clearSession(id)
}
/** /**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models — * Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба * кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
@@ -126,11 +351,10 @@ class PhoneApp : Application() {
synchronized(sttLock) { synchronized(sttLock) {
sttStreamer?.let { return it } sttStreamer?.let { return it }
val modelsDir = File(filesDir, "models") val modelsDir = File(filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx")
val encoder = File(modelsDir, "small-encoder.int8.onnx") val encoder = File(modelsDir, "small-encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx") val decoder = File(modelsDir, "small-decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt") val tokens = File(modelsDir, "small-tokens.txt")
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) { if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null return null
} }
@@ -140,21 +364,28 @@ class PhoneApp : Application() {
tokensPath = tokens.absolutePath, tokensPath = tokens.absolutePath,
numThreads = 4, numThreads = 4,
) )
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw")
} else {
null
}
val s = SttStreamer( val s = SttStreamer(
stt = whisper, stt = whisper,
vadModelPath = vadModel.absolutePath,
onPhrase = { phrase, full ->
log("stt", "фраза: $phrase")
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
scope.launch { assistantChannel.send(phrase) }
},
onSilence30s = { onSilence30s = {
log("stt", "тишина 30 с — автo-отмена") log("stt", "тишина 30 с — автo-отмена")
scope.launch { server.hub.broadcast(SttCancel()) } scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
}, },
onAutoFinished = { full ->
log("stt", "тишина 30 с — ВЕСЬ ТЕКСТ: $full")
// Тот же путь, что и результат по клику: в очки SttDone + в LLM.
scope.launch { server.hub.broadcast(SttDone(full)) }
scope.launch { assistantChannel.send(full.trim()) }
},
dumpPcm = dumpPcm,
) )
sttStreamer = s sttStreamer = s
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)") log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов (Whisper-small int8)")
return s return s
} }
} }
@@ -189,10 +420,17 @@ class PhoneApp : Application() {
super.onCreate() super.onCreate()
instance = this instance = this
PlaybackService.start(this)
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
MercuryBridge.init(this)
server.start() server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
DebugHttpServer.start(this)
nsd.publish() nsd.publish()
server.hub.sttFactory = { ensureStt() } server.hub.sttFactory = { ensureStt() }
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
scope.launch { assistantLoop() } scope.launch { assistantLoop() }
val lastPositionAt = AtomicLong(0) val lastPositionAt = AtomicLong(0)
@@ -264,6 +502,7 @@ class PhoneApp : Application() {
} }
override fun onTerminate() { override fun onTerminate() {
PlaybackService.stop(this)
nsd.unpublish() nsd.unpublish()
sttStreamer?.close() sttStreamer?.close()
server.stop() server.stop()
@@ -12,6 +12,13 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080 const val SERVER_PORT = 8080
/**
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
*/
const val USE_BLUETOOTH = true
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */ /** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
const val AUDIO_SILENCE_PAUSE_MS = 5_000L const val AUDIO_SILENCE_PAUSE_MS = 5_000L
@@ -0,0 +1,75 @@
package pw.binom.viewmate.phone
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Foreground service типа mediaPlayback: держит процесс телефона живым (WS-сервер +
* аудио-плеер) при выключенном экране. Без него HONOR замораживает/убивает процесс
* в фоне → очки теряют связь (видео паузится) и звук встаёт. Является спутником
* WS-сервера: стартует в PhoneApp.onCreate, останавливается в onTerminate.
*/
class PlaybackService : Service() {
companion object {
const val CHANNEL_ID = "viewmate_playback"
const val NOTIFICATION_ID = 1
fun start(context: Context) {
ContextCompat.startForegroundService(
context, Intent(context, PlaybackService::class.java)
)
}
fun stop(context: Context) {
context.stopService(Intent(context, PlaybackService::class.java))
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel(
CHANNEL_ID, "ViewMate playback",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("ViewMate")
.setContentText("Звук на телефоне при просмотре на очках")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
// держим CPU и WiFi живыми при выключенном экране
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
?.apply { acquire() }
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
?.apply { acquire() }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
START_STICKY
override fun onDestroy() {
wakeLock?.takeIf { it.isHeld }?.release()
wifiLock?.takeIf { it.isHeld }?.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,98 @@
package pw.binom.viewmate.phone
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.delay
import pw.binom.viewmate.core.net.GlassesServerTransport
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* WiFi-транспорт, сторона телефона («сервер») — TASK-transport.md п.2:
* [GlassesServerTransport] на Ktor-CIO-сервере (WebSocket [GLASSES_WS_PATH]).
* Сессии — [connId] (UUID-строки, не WS-объекты): мост между WS-сессией и
* connId живёт здесь, [GlassesHub] знает только строки.
*/
class WifiServerTransport(
private val port: Int = PhoneConfig.SERVER_PORT,
) : GlassesServerTransport {
override val name = "wifi"
private val sessionByConn = ConcurrentHashMap<String, DefaultWebSocketServerSession>()
@Volatile
private var server: EmbeddedServer<*, *>? = null
@Volatile
private var closed = false
/** Бесконечно принимать очки: старт Ktor-сервера + холд (пока [close]). */
override suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesWsModule(onMessage, onConnected, onDisconnected)
}
server!!.start(wait = false)
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
while (!closed) {
delay(500)
}
}
/** Ktor-модуль: WS-роут очков; сессия адресуется connId (UUID), события — колбэки [accept]. */
private fun Application.glassesWsModule(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
install(WebSockets) {
pingPeriodMillis = 5_000
timeoutMillis = 5_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(GLASSES_WS_PATH) {
val connId = UUID.randomUUID().toString()
sessionByConn[connId] = this
onConnected(connId)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
onMessage(connId, frame.readText())
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
sessionByConn.remove(connId)
onDisconnected(connId)
}
}
}
}
override suspend fun send(connId: String, json: String) {
sessionByConn[connId]?.send(json)
}
override fun close() {
closed = true
runCatching { server?.stop(1_000, 3_000) }
server = null
sessionByConn.clear()
}
}
@@ -0,0 +1,267 @@
package pw.binom.viewmate.phone.agent
import pw.binom.viewmate.core.log
import java.util.concurrent.ConcurrentHashMap
/**
* Механика агента: ядро тулов + тулсеты + скиллы + сборка системного
* промпта. Чистая JVM-логика (без Android), юнит-тестируется.
*
* Три состояния промпта (пересобирается на каждый запрос):
* 1. Набор не активен: ядро + каталог тулсетов + индекс скиллов.
* 2. Активация: enable пересобирает промпт, сам вызов в историю НЕ пишется
* («системная тула-невидимка»).
* 3. Активен: ядро + описания тул набора + «Активные тулсеты: имя»;
* скилл, связанный с набором (имя скилла = имя тулсета), УБРАН из промпта.
*
* Бесшовная подмена: тул набора при НЕактивном наборе не выполняется вслепую —
* подменяется на enable_toolset|набор и модели явно сообщается, что вызов ещё
* НЕ выполнялся (иначе она выдумает результат). Если аргументы совпадают со
* схемой и исполнитель есть — вызов выполняется сразу, без второго круга.
*/
class AgentToolkit(
private val skills: SkillRegistry,
private val toolsets: ToolsetRegistry,
private val showText: ((String) -> Unit)? = null,
) {
companion object {
const val TOOL_TIME = "get_current_time"
const val TOOL_SHOW_TEXT = "show_text"
const val TOOL_READ_SKILL = "read_skill"
const val TOOL_ENABLE = "enable_toolset"
const val TOOL_DISABLE = "disable_toolset"
/** Ядро: 5 тул, всегда в промпте, компактно. */
val CORE_TOOL_NAMES: List<String> =
listOf(TOOL_TIME, TOOL_SHOW_TEXT, TOOL_READ_SKILL, TOOL_ENABLE, TOOL_DISABLE)
private val CORE_LINES = listOf(
"get_current_time — текущие дата, время, часовой пояс (ISO 8601).",
"show_text|text — показать текст на очках.",
"read_skill|имя — прочитать полное содержимое скилла по имени.",
"enable_toolset|имя — активировать тулсет (живёт 10 мин от последнего вызова его тул).",
"disable_toolset|имя — деактивировать тулсет.",
)
}
/** Одноразовые напоминания по сессиям (невидимые тулы: «уже активен», «набора нет»). */
private val pendingNotes = ConcurrentHashMap<Long, String>()
// ---------- сборка промпта ----------
/**
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
*/
fun systemPrompt(sessionId: Long): String {
val note = pendingNotes.remove(sessionId)
val activeSets = toolsets.activeNames(sessionId)
val sb = StringBuilder()
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
.append("После получения результата тула ответь пользователю текстом.")
sb.append("\n\nЯдро:")
CORE_LINES.forEach { sb.append('\n').append(it) }
val catalog = toolsets.catalogLines()
if (catalog.isNotEmpty()) {
sb.append("\n\nКаталог тулсетов (активация — enable_toolset|имя):")
catalog.forEach { sb.append('\n').append(it) }
}
if (activeSets.isNotEmpty()) {
sb.append("\n\nАктивные тулсеты: ").append(activeSets.joinToString(", "))
activeSets.forEach { name ->
toolsets.toolset(name)?.toolLines()?.forEach { sb.append('\n').append(it) }
}
}
val skillLines = skills.index(excluded = activeSets.toSet())
if (skillLines.isNotEmpty()) {
sb.append("\n\nСкиллы (read_skill|имя):")
skillLines.forEach { sb.append('\n').append(it) }
}
if (note != null) sb.append("\n\nВажно: ").append(note)
return sb.toString()
}
// ---------- парсинг аргументов ----------
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
fun schemaFor(tool: String): List<String> = when (tool) {
TOOL_TIME -> emptyList()
TOOL_SHOW_TEXT -> listOf("text")
TOOL_READ_SKILL -> listOf("name")
TOOL_ENABLE, TOOL_DISABLE -> listOf("name")
else -> toolsets.signatureOf(tool)?.args ?: emptyList()
}
/**
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
*/
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
val s = raw?.trim()
if (s.isNullOrEmpty()) return emptyMap()
val required = expected.filterNot { it.endsWith("?") }.distinct()
val out = LinkedHashMap<String, String>()
var bareIndex = 0
for (field in s.split(ALTER)) {
val f = field.trim()
if (f.isEmpty()) continue
val eq = f.indexOf('=')
if (eq > 0) {
val key = f.substring(0, eq).trim()
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
} else if (bareIndex < required.size) {
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
out[required[bareIndex++]] = f
}
}
return out
}
private val ALTER = Regex("[,&]")
// ---------- диспач вызовов ----------
/**
* Результат диспача:
* [Run] — результат пишется в историю (TOOL-сообщение);
* [Silent] — невидимая системная тула (enable/disable_toolset): в историю
* НЕ пишется, просто пересобирается промпт на следующем запросе.
*/
sealed interface Dispatch {
class Run(val result: String) : Dispatch
object Silent : Dispatch
}
/**
* Диспач вызова тула: ядро + префиксная маршрутизация в тулсеты.
* Все тулы идемпотентные и прощающие; аргументы валидируются по реестру.
*/
fun dispatch(sessionId: Long, tool: String, args: Map<String, String>): Dispatch {
when (tool) {
TOOL_TIME -> return Dispatch.Run(TimeTool.invoke("{}"))
TOOL_SHOW_TEXT -> {
val text = args["text"]
if (text.isNullOrBlank()) {
return Dispatch.Run(
"show_text: аргументы неверны, не хватает \"text\". Схема: show_text|text=<текст>."
)
}
if (showText == null) {
return Dispatch.Run("Текст \"$text\" не показан: отображение на очки не подключено.")
}
showText(text)
log("toolset", "show_text (сессия $sessionId): $text")
return Dispatch.Run("Показано на очках: $text")
}
TOOL_READ_SKILL -> {
val name = args["name"]
if (name.isNullOrBlank()) {
return Dispatch.Run("read_skill: аргументы неверны, не хватает \"name\". Схема: read_skill|имя=<скилл>.")
}
val content = skills.readFull(name)
?: return Dispatch.Run("Скилл \"$name\" не найден. " + skillsAvailable())
log("toolset", "read_skill (сессия $sessionId): $name")
return Dispatch.Run(content)
}
TOOL_ENABLE -> {
val name = args["name"]
if (name.isNullOrBlank()) {
return Dispatch.Run("enable_toolset: аргументы неверны, не хватает \"name\". Схема: enable_toolset|имя=<тулсет>.")
}
when (val r = toolsets.enable(sessionId, name)) {
is EnableResult.Activated ->
setNote(sessionId, if (r.alreadyActive) "Тулсет \"$name\" уже активен, таймаут продлён." else null)
is EnableResult.Unknown -> setNote(sessionId, r.message)
}
log("toolset", "enable_toolset (сессия $sessionId): $name")
return Dispatch.Silent
}
TOOL_DISABLE -> {
val name = args["name"]
if (name.isNullOrBlank()) {
return Dispatch.Run("disable_toolset: аргументы неверны, не хватает \"name\". Схема: disable_toolset|имя=<тулсет>.")
}
when (val r = toolsets.disable(sessionId, name)) {
is DisableResult.Disabled -> setNote(sessionId, null)
is DisableResult.NotActive -> setNote(sessionId, r.message)
}
log("toolset", "disable_toolset (сессия $sessionId): $name")
return Dispatch.Silent
}
}
// Префиксная маршрутизация: media_* → набор media.
val ts = toolsets.findByPrefix(tool)
?: return Dispatch.Run("Тул \"$tool\" не найден. " + available())
val sig = ts.signatureOf(tool)
?: return Dispatch.Run(
"Тул \"$tool\" в тулсете \"${ts.name}\" нет. Тулы тулсета: " +
ts.toolLines().joinToString("; ") + "."
)
if (!toolsets.isActive(sessionId, ts.name)) {
// Бесшовная подмена: набор неактивен — активируем его (как будто
// вызван enable_toolset|набор) и явно говорим, что вызов ещё не выполнен.
toolsets.enable(sessionId, ts.name)
log("toolset", "подмена $tool → enable_toolset|${ts.name} (сессия $sessionId)")
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
val executor = toolsets.executor(ts.name)
if (missing.isEmpty() && executor != null) {
val result = executor.execute(ts.name, tool, args)
toolsets.touch(sessionId, ts.name)
return Dispatch.Run(result)
}
return Dispatch.Run(substitutionMessage(ts, tool, sig))
}
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
if (missing.isNotEmpty()) {
val schema = buildString {
append(tool)
if (sig.args.isNotEmpty()) append('|').append(sig.args.joinToString(","))
}
return Dispatch.Run(
"$tool: аргументы неверны, не хватает обязательных: ${missing.joinToString(", ")}. Схема: $schema."
)
}
val executor = toolsets.executor(ts.name)
?: return Dispatch.Run("Тулсет \"${ts.name}\" активен, но его тулы ещё не реализованы.")
val result = executor.execute(ts.name, tool, args)
toolsets.touch(sessionId, ts.name)
log("toolset", "$tool (сессия $sessionId) → $result")
return Dispatch.Run(result)
}
/** Сообщение подмены: набор активирован + реестр + явная пометка «вызов ещё не выполнен». */
private fun substitutionMessage(ts: Toolset, tool: String, sig: ToolSignature): String {
val registry = ts.toolLines().joinToString("; ")
val usage = when {
sig.args.isEmpty() -> "Для выполнения вызови $tool."
else -> "Для выполнения вызови $tool с аргументами: ${sig.argNames().joinToString(", ")}."
}
return "Тулсет ${ts.name} активирован. Реестр: $registry. " +
"Вызов $tool ещё НЕ выполнялся. $usage"
}
private fun setNote(sessionId: Long, note: String?) {
if (note == null) pendingNotes.remove(sessionId) else pendingNotes[sessionId] = note
}
private fun skillsAvailable(): String =
"Доступные скиллы: " + skills.names().joinToString(", ").ifBlank { "нет скиллов" }
private fun available(): String {
val sets = toolsets.names()
return "Доступны: " + CORE_TOOL_NAMES.joinToString(", ") +
if (sets.isEmpty()) ". Тулсетов нет." else ". Тулсеты: " + sets.joinToString(", ") + "."
}
}
@@ -0,0 +1,53 @@
package pw.binom.viewmate.phone.agent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Тул ассистента: локальная функция, которую агент (LLM) может вызвать по
* имени. Результат возвращается строкой и подставляется в историю как
* tool-сообщение (роль [ChatRole.TOOL]).
*
* Добавление нового тула: реализовать [AgentTool], добавить в список
* [AgentTools.ALL]. Агент узнаёт о тулах из системного промпта (описание
* [describe]) — никакой магии.
*/
interface AgentTool {
/** Имя тула (как агент его называет, например `get_current_time`). */
val name: String
/** Короткое описание для системного промпта (что делает, что возвращает). */
val description: String
/** Вызвать тул. [arguments] — JSON-строка (пустая, если аргументов нет). */
fun invoke(arguments: String): String
}
/** Реестр тулов ассистента. */
object AgentTools {
val ALL: List<AgentTool> = listOf(TimeTool)
/** Описания для системного промпта: «Тул: name — description». */
fun describeAll(): String = ALL.joinToString("\n") { "Тул: ${it.name} — ${it.description}" }
}
/**
* Тул времени: возвращает текущие дату, время и часовой пояс в ISO 8601
* (например `2026-08-22T03:14:05+03:00[Europe/Moscow]`). Локально на
* телефоне — LLM не нужна, время точное.
*/
object TimeTool : AgentTool {
override val name = "get_current_time"
override val description =
"Текущие дата и время с часовым поясом в формате ISO 8601. Аргументы не нужны (пустой JSON {})."
private val formatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
override fun invoke(arguments: String): String {
val now = Instant.now()
val zone = ZoneId.systemDefault()
return formatter.format(now.atZone(zone))
}
}
@@ -7,31 +7,153 @@ import kotlinx.coroutines.withTimeoutOrNull
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM, * LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
* история накапливается в сессии. Синхронный вызов из одной корутины * история накапливается в сессии. Синхронный вызов из одной корутины
* (сериализация фраз — LLM-вызовы не пересекаются). * (сериализация фраз — LLM-вызовы не пересекаются).
*
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
* затем делает повторный вызов LLM. Невидимые системные тулы
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
* возвращается пользователю как финальный ответ.
*/ */
class Assistant( class Assistant(
private val chatDao: ChatDao, private val chatDao: ChatDao,
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String, private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
private val toolkit: AgentToolkit? = null,
private val tools: List<AgentTool> = AgentTools.ALL,
private val timeoutMs: Long = 30_000, private val timeoutMs: Long = 30_000,
) { ) {
/** /**
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей * Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM — * вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение. * выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
*/ */
suspend fun process(text: String): String { suspend fun process(text: String): String {
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент") val sessionId = chatDao.latestSessionId() ?: chatDao.createSession(randomTitle())
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text) chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
return processInSession(sessionId)
}
/** Обработать фразу в КОНКРЕТНОЙ сессии [sessionId] (переключение сессий). */
suspend fun processInSession(sessionId: Long, text: String): String {
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
// Сессия безымянная (создана кнопкой «новая сессия») — имя из первого сообщения.
val s = chatDao.getSession(sessionId)
if (s != null && s.title.isBlank()) {
val t = text.trim().take(30)
chatDao.renameSession(sessionId, if (t.isEmpty()) "беседка" else t)
}
return processInSession(sessionId)
}
private suspend fun processInSession(sessionId: Long): String {
val history = chatDao.messagesForSession(sessionId).takeLast(20) val history = chatDao.messagesForSession(sessionId).takeLast(20)
val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) } var answer = callLlm(history, sessionId)
var lastToolResult: String? = null
var visibleCalls = 0
var llmCalls = 1
// Цикл тулов: до [MAX_TOOL_CALLS] видимых вызовов подряд (защита от
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
while (true) {
val parsed = parseToolCall(answer)
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
val toolName = parsed.name
if (toolkit == null) {
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
return "Тул $toolName не найден"
}
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
logTool(sessionId, toolName, lastToolResult)
if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++
continue
}
when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) {
is AgentToolkit.Dispatch.Run -> {
lastToolResult = result.result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
logTool(sessionId, toolName, result.result)
if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++
}
is AgentToolkit.Dispatch.Silent -> {
// Невидимая системная тула (enable/disable_toolset): вызов не
// пишется в историю, промпт пересобирается на следующем callLlm
// (переход в состояние 3 — «Активные тулсеты»).
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
if (llmCalls++ >= MAX_LLM_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
}
}
}
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
// чтобы пользователь получил данные, а не сырой маркер.
val stillTool = parseToolCall(answer)
return if (stillTool != null && lastToolResult != null) {
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
} else {
answer
}
}
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
?: return "Ассистент не ответил (таймаут)" ?: return "Ассистент не ответил (таймаут)"
if (!isToolCall(answer)) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
chatDao.touchSession(sessionId) chatDao.touchSession(sessionId)
}
return answer return answer
} }
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
private fun legacySystemPrompt(): String {
val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски."
if (tools.isEmpty()) return base
return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}"
}
companion object { companion object {
// TODO(персона): промпт выберет пользователь /** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */
const val SYSTEM_PROMPT = const val MAX_TOOL_CALLS = 3
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
const val MAX_LLM_CALLS = 8
/**
* Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
*/
private val TOOL_CALL_RE =
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
data class ParsedToolCall(val name: String, val argsRaw: String?)
private fun parseToolCall(answer: String): ParsedToolCall? {
val m = TOOL_CALL_RE.find(answer) ?: return null
val argsRaw = m.groupValues[2].trim().ifBlank { null }
return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw)
}
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
/** Случайное имя сессии (UUID-подобное, 8 символов). */
private fun randomTitle(): String =
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
private fun logTool(sessionId: Long, toolName: String, result: String) {
pw.binom.viewmate.core.log("assistant", "тул $toolName (сессия $sessionId) → $result")
}
} }
} }
@@ -32,6 +32,25 @@ class ChatDao(private val db: ChatDb) {
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null } return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
} }
/** Сессия по id, или null, если не существует. */
fun getSession(id: Long): Session? {
val cursor = db.readableDatabase.rawQuery(
"SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?",
arrayOf(id.toString()),
)
return cursor.use { c -> if (c.moveToFirst()) c.toSession() else null }
}
/** Сброс сессии: удалить все сообщения, сессия остаётся пустой. */
fun clearSession(id: Long) {
db.writableDatabase.delete(
ChatDb.TABLE_MESSAGES,
"session_id = ?",
arrayOf(id.toString()),
)
touchSession(id)
}
fun renameSession(id: Long, title: String) { fun renameSession(id: Long, title: String) {
db.writableDatabase.update( db.writableDatabase.update(
ChatDb.TABLE_SESSIONS, ChatDb.TABLE_SESSIONS,
@@ -0,0 +1,40 @@
package pw.binom.viewmate.phone.agent
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.JellyfinItem
/**
* [MediaSource] поверх Jellyfin: search/item через [JellyfinClient].
* [userId] — кэш приложения (PhoneApp.userId, подтягивается при старте),
* передан провайдером: если к моменту вызова ещё не готов — [MediaSourceException]
* (исполнитель покажет модели «Поиск недоступен: ...», а не пахнет пустым результатом).
*/
class JellyfinMediaSource(
private val jellyfin: JellyfinClient,
private val userId: () -> String,
) : MediaSource {
override suspend fun search(query: String, limit: Int): List<MediaItem> {
val user = requireUserAvailable()
return jellyfin.search(user, query).take(limit).map { jellyfinToMediaItem(it) }
}
override suspend fun item(itemId: String): MediaItem? {
val user = requireUserAvailable()
return jellyfin.item(user, itemId)?.let { jellyfinToMediaItem(it) }
}
private fun requireUserAvailable(): String =
userId().trim().ifBlank { throw MediaSourceException("нет пользователя Jellyfin (userId пуст)") }
}
/** Модель Jellyfin → тул-элемент (JVM, тестируемо: поля, «постер есть», длительность в секундах). */
fun jellyfinToMediaItem(item: JellyfinItem): MediaItem = MediaItem(
id = item.Id,
name = item.Name?.trim()?.takeIf { it.isNotEmpty() } ?: item.Id,
type = item.Type,
year = item.ProductionYear,
overview = item.Overview,
durationSeconds = item.RunTimeTicks?.let { it / 10_000_000 },
hasPoster = item.ImageTags?.containsKey("Primary") == true,
)
@@ -1,64 +1,125 @@
package pw.binom.viewmate.phone.agent package pw.binom.viewmate.phone.agent
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient import kotlinx.coroutines.Dispatchers
import ai.koog.prompt.dsl.prompt import kotlinx.coroutines.withContext
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings import kotlinx.serialization.Serializable
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient import kotlinx.serialization.encodeToString
import ai.koog.prompt.llm.LLMCapability import kotlinx.serialization.json.Json
import ai.koog.prompt.llm.LLModel import okhttp3.MediaType.Companion.toMediaType
import ai.koog.prompt.llm.LLMProvider import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/** /**
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт * LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions"). * → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
* *
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties). * Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо). * («Ассистент недоступен: …»), сам клиент не падает тихо.
*
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
*/ */
class LlmClient( interface LlmClient {
/**
* [messages] — история диалога (последнее сообщение — текущее обращение),
* [system] — системный промпт тулкита/настроек или null (не обязателен).
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
}
/**
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
* https://llm.binom.pw/v1/chat/completions.
*
* Koog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
* content на длинных историях (после tool-вызовов) — см. заметку
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
* модель отвечает стабильно, тул-цикл работает.
*
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
* [http] инжектируется (дефолт — свой клиент с таймаутами).
*/
class RemoteLlmClient(
private val apiKey: String, private val apiKey: String,
private val baseUrl: String = "https://llm.binom.pw", private val baseUrl: String = "https://llm.binom.pw",
private val model: String, // модель укажет пользователь private val model: String,
) { private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build(),
) : LlmClient {
private val client: OpenAILLMClient = OpenAILLMClient( private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
apiKey = apiKey,
settings = OpenAIClientSettings(baseUrl = baseUrl), @Serializable
httpClientFactory = OkHttpKoogHttpClient.Factory(), private data class ChatMessageDto(
val role: String,
val content: String,
) )
private val llmModel: LLModel = LLModel( @Serializable
provider = LLMProvider.OpenAI, private data class ChatRequest(
id = model, val model: String,
capabilities = listOf( val messages: List<ChatMessageDto>,
LLMCapability.Completion, val max_tokens: Int = 1024,
LLMCapability.OpenAIEndpoint.Completions, val temperature: Double = 0.3,
),
) )
@Serializable
private data class ChatResponse(
val choices: List<Choice> = emptyList(),
val error: ApiError? = null,
) {
@Serializable
data class Choice(val message: Message = Message())
@Serializable
data class Message(val content: String? = null)
@Serializable
data class ApiError(val message: String? = null)
}
/** /**
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга). * Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
* System-сообщение, если задано, добавляется первым. * System-сообщение, если задано, добавляется первым. TOOL-сообщения —
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
*/ */
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String { override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
val prompt = prompt("assistant-chat") { val dto = ArrayList<ChatMessageDto>()
if (system != null) { if (system != null) dto += ChatMessageDto("system", system)
this.system(system)
}
for (msg in messages) { for (msg in messages) {
when (msg.role) { when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content) ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content) ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content) ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов ChatRole.TOOL -> if (msg.content.isNotBlank()) {
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
ChatRole.TOOL -> Unit
} }
} }
} }
return client.execute(prompt, llmModel).textContent() if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
val request = Request.Builder()
.url("$baseUrl/v1/chat/completions")
.header("Authorization", "Bearer $apiKey")
.header("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
return withContext(Dispatchers.IO) {
http.newCall(request).execute().use { resp ->
val raw = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
}
val text = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull()
text?.trim() ?: ""
}
}
} }
} }
@@ -15,7 +15,7 @@ object LlmDebug {
log("llm", "llm.apiKey не задан в local.properties — пропускаю") log("llm", "llm.apiKey не задан в local.properties — пропускаю")
return return
} }
val client = LlmClient(apiKey = apiKey, model = model) val client = RemoteLlmClient(apiKey = apiKey, model = model)
try { try {
val reply = runBlockingSafe { val reply = runBlockingSafe {
client.chat( client.chat(
@@ -0,0 +1,28 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import android.content.SharedPreferences
/**
* Персистентный выбор модели LLM ассистента ([LlmModelChoice]) —
* SharedPreferences «llm-choice». Дефолт (пусто/неизвестно) — [LlmModelChoice.REMOTE].
*
* Чистая логика парсинга — [LlmModelChoice.parse] (JVM-тестируемая);
* этот класс только обёртка над SharedPreferences.
*/
class LlmPrefs(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun current(): LlmModelChoice = LlmModelChoice.parse(prefs.getString(KEY_MODEL_CHOICE, null))
fun set(choice: LlmModelChoice) {
prefs.edit().putString(KEY_MODEL_CHOICE, choice.name).apply()
}
private companion object {
const val PREFS_NAME = "llm-choice"
const val KEY_MODEL_CHOICE = "model_choice"
}
}
@@ -0,0 +1,416 @@
package pw.binom.viewmate.phone.agent
import com.google.ai.edge.litertlm.Backend
import com.google.ai.edge.litertlm.Content
import com.google.ai.edge.litertlm.Contents
import com.google.ai.edge.litertlm.Conversation
import com.google.ai.edge.litertlm.ConversationConfig
import com.google.ai.edge.litertlm.Engine
import com.google.ai.edge.litertlm.EngineConfig
import com.google.ai.edge.litertlm.ExperimentalApi
import com.google.ai.edge.litertlm.ExperimentalFlags
import com.google.ai.edge.litertlm.Message
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message)
/** Обрезает сообщение до 200 символов, оставляя читаемые (ASCII + кириллица). */
private fun sanitize(msg: String?): String {
if (msg == null) return "null"
val sb = StringBuilder()
for (ch in msg.take(200)) {
val c = ch.code
if ((c in 32..126) || (c >= 0x410 && c <= 0x44F)) sb.append(ch)
}
return sb.toString()
}
/**
* Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp),
* переключается UI-блоком в настройках экрана «Телефон»:
* - [REMOTE] — сервер llm.binom.pw ([RemoteLlmClient]: OpenAI-совместимый HTTP);
* - [LOCAL] — Gemma 4 E2B на телефоне ([LocalLlmClient], LiteRT-LM, GPU/CPU).
*/
enum class LlmModelChoice {
REMOTE, LOCAL;
companion object {
/** Значение из настроек; пустое/неизвестное имя → [REMOTE] (дефолт). */
fun parse(name: String?): LlmModelChoice =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: REMOTE
}
}
/**
* Gemma 4 E2B (LiteRT-LM): локальный LLM на телефоне.
* Модель — файл [FILE_NAME] в getExternalFilesDir(null) телефона;
* докачивается внутри приложения (см. [GemmaModelDownloader]).
* API-ноты: docs/litertlm-openassistant-notes.md в репо.
*/
object Gemma4E2B {
const val FILE_NAME = "gemma-4-E2B-it.litertlm"
const val SHA256 = "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c"
/** Размер файла для UI (2 588 147 712 байта). */
const val SIZE_HUMAN = "≈2.5 ГБ"
/** Источники скачивания по приоритету: HuggingFace (открытый), затем зеркало. */
val DOWNLOAD_URLS = listOf(
"https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm",
"https://data.vayunmathur.com/models/gemma-4-E2B-it.litertlm",
)
/**
* Конфиг движка — точь-в-точь как в референсе vayunmathur/Modern-Apps
* (InferenceService.kt): GPU для основного и vision, CPU для аудио.
* Ключ: cacheDir должен быть системным applicationContext.cacheDir —
* GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров;
* свой подкаталог в filesDir приводил к падению на delegate_kernel.cc.
*/
fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
modelPath = modelFile.absolutePath,
// NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497)
// в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на
// этом HONOR гоняет весь движок (включая vision-сабаграф) на WebGPU; ранее
// мы режут vision на CPU из-за 128 МБ-лимита буфера, но чистый текст-чат
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
// картинке — вернём vision на CPU точечно.
backend = Backend.GPU(),
visionBackend = Backend.GPU(),
audioBackend = Backend.CPU(),
cacheDir = cacheDir.absolutePath,
)
}
// ---------- Чистые вспомогательные (JVM-тестируемые) ----------
/** hex SHA-256 содержимого файла потоково (по 64 КБ — файл ~2.5 ГБ). */
fun sha256HexOfFile(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(64 * 1024)
while (true) {
val readSize = input.read(buf)
if (readSize < 0) break
digest.update(buf, 0, readSize)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
/** true, если [file] существует и его SHA-256 совпадает с [expectedHex]. */
fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
file.isFile && sha256HexOfFile(file).equals(expectedHex, ignoreCase = true)
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
class LocalLlmPrompt(
val systemInstruction: String?,
val initialMessages: List<Message>,
val incoming: Message,
)
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
.joinToString("") { it.text }
/**
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
* - USER → [Message.user], ASSISTANT → [Message.model];
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж);
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
* - пустые content отбрасываются.
*
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL:
* оно уходит в sendMessageAsync, остальное — история (initialMessages).
* Если последние сообщения только модельные/история пуста — incoming = «Продолжай.»
* (аналог fallback [RemoteLlmClient]).
*/
fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt {
val systemParts = ArrayList<String>()
system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
val mapped = ArrayList<Message>()
for (msg in messages) {
val text = msg.content
when (msg.role) {
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
ChatRole.USER -> if (text.isNotBlank()) mapped += Message.user(Contents.of(text))
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(text))
ChatRole.TOOL -> if (text.isNotBlank()) {
mapped += Message.user(Contents.of("Результат тула ${msg.toolCallId ?: "?"}: $text"))
}
}
}
val last = messages.lastOrNull { it.content.isNotBlank() }
val incoming: Message
val initial: List<Message>
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
initial = mapped.dropLast(1)
incoming = mapped.last()
} else {
initial = mapped
incoming = Message.user(Contents.of("Продолжай."))
}
return LocalLlmPrompt(
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
initialMessages = initial,
incoming = incoming,
)
}
// ---------- Скачивание модели ----------
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
sealed interface LocalLlmModelState {
/** Модель не скачана (или ещё не скачивалась). */
object Idle : LocalLlmModelState
/** Скачивание в пути; [percent] — 0..99. */
data class Downloading(val percent: Int) : LocalLlmModelState
/** Модель скачана и проверена (SHA-256 совпал при скачивании). */
object Ready : LocalLlmModelState
/** Ошибка скачивания: [message]. */
data class Error(val message: String) : LocalLlmModelState
}
/**
* Скачивание Gemma 4 E2B ([Gemma4E2B.DOWNLOAD_URLS] по порядку):
* OkHttp-стриминг в файл .part, прогресс в процентах через колбэк,
* SHA-256-контроль после загрузки (несовпал — удалить и взять следующий
* исходник), после успешного — перенос на [target]. Прогресс — из IO-потока
* (колбэк должен быть потоковезбёчным; в PhoneApp — запись в StateFlow).
*/
class GemmaModelDownloader(
private val target: File,
private val urls: List<String> = Gemma4E2B.DOWNLOAD_URLS,
private val expectedSha256: String = Gemma4E2B.SHA256,
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build(),
) {
/**
* Скачать модель в [target]. Бросает [IOException] «Модель не скачалась…»
* (все источники исчерпаны / контрольная сумма). [onProgressPercent] —
* 0..99 во время скачивания (вызывается из IO-потока).
*/
suspend fun download(onProgressPercent: (Int) -> Unit) {
target.parentFile?.mkdirs()
val partial = File(target.parentFile, target.name + ".part")
var lastError: String? = null
for (url in urls) {
var ok: Boolean
try {
ok = downloadFrom(url, partial, onProgressPercent)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
ok = false
lastError = e.message ?: e.toString()
}
log("llm", "скачивание модели из $url: ${if (ok) "ок" else "не удалось ($lastError)"}")
if (ok) {
log("llm", "модель скачана: ${target.length()} байт, SHA-256 совпадает")
return
}
partial.delete()
}
partial.delete()
throw IOException("Модель не скачалась (все источники): $lastError — повторите позже")
}
private suspend fun downloadFrom(
url: String,
partial: File,
onProgressPercent: (Int) -> Unit,
): Boolean = withContext(Dispatchers.IO) {
http.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (!response.isSuccessful) {
log("llm", "HTTP ${response.code} с $url")
return@withContext false
}
val body = response.body ?: return@withContext false
val totalBytes = body.contentLength()
var received = 0L
var lastPercent = -1
partial.outputStream().use { out ->
body.byteStream().use { input ->
val buf = ByteArray(256 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
received += n
val percent = if (totalBytes > 0) ((received * 100) / totalBytes).toInt()
else ((received / (4 * 1024 * 1024)) * 25).toInt().coerceAtMost(99)
if (percent > lastPercent) {
lastPercent = percent
log("llm", "модель: скачано ${received / (1024 * 1024)} МБ" +
(if (totalBytes > 0) " ($percent%)" else ""))
onProgressPercent(percent)
}
}
}
}
if (!sha256OfFileMatches(partial, expectedSha256)) {
log("llm", "SHA-256 модели не совпал — перезаписываем")
partial.delete()
return@withContext false
}
partial.renameTo(target)
true
}
}
}
// ---------- Клиент ----------
/**
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md).
*
* Жизненный цикл: [Engine] создаётся один раз (лениво, при первом [chat]),
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
* новый Conversation (своей истории у LiteRT-LM нет — она в наших
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
* пользователю, падение приложения исключено.
*
* Модель-файл: [modelFile] (getExternalFilesDir(null)); [cacheDir] —
* кэш-каталог движка (cacheDir, не tmpDir — tmp-файлы удаляются системой).
*/
class LocalLlmClient(
private val modelFile: File,
private val cacheDir: File,
) : LlmClient {
private val engineLock = Any()
@Volatile
private var engine: Engine? = null
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */
fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
val engine = ensureEngine()
log("llm", "chat: движок готов, строю промпт")
val prompt = buildLocalLlmPrompt(messages, system)
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text().take(60)}")
val conversation: Conversation = synchronized(engineLock) {
engine.createConversation(
ConversationConfig(
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) },
initialMessages = prompt.initialMessages,
)
)
}
log("llm", "chat: conversation создан")
try {
var text = ""
var chunks = 0
var tokens = 0
val t0 = System.currentTimeMillis()
var firstTokenAt = 0L
withTimeout(120_000) {
// NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт
// emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом
// dex null — артефакт маски $default, не аргумент.
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
chunks++
val chunkText = messageChunk.text()
text += chunkText
if (chunkText.isNotEmpty()) {
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
tokens += 1
}
if (chunks <= 3 || chunks % 20 == 0) {
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}")
}
}
}
val totalMs = System.currentTimeMillis() - t0
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)")
return text.trim()
} catch (e: CancellationException) {
log("llm", "chat: отменено")
runCatching { conversation.cancelProcess() }
throw e
} catch (e: Exception) {
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
log("llm", "chat: EXC msg=${sanitize(e.message)}")
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
} finally {
runCatching { conversation.close() }
}
}
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
fun close() {
val eng = synchronized(engineLock) {
val e = engine
engine = null
e
}
runCatching { eng?.close() }
}
/** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */
@OptIn(ExperimentalApi::class)
private fun applyExperimentalFlags() {
// NOTE 2026-08-23 (porfiry): ранее выключали спекуляцию, потому что статический
// TopK-сэмплер при enableSpeculativeDecoding=false тянет OpenCL-драйвер,
// которого нет в app-namespace ("Can not find OpenCL library"). Автор включил
// её TRUE: тогда движок использует статический WebGPU-сэмплер (он на HONOR
// работает — оригинал идёт, TTFB ~0.8с тёплый). Берём его флаг как есть.
ExperimentalFlags.enableSpeculativeDecoding = true
}
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
private fun ensureEngine(): Engine {
val cached = engine
if (cached != null) return cached
return synchronized(engineLock) {
val existing = engine
if (existing != null) return existing
if (!modelFile.isFile) {
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
}
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
}
applyExperimentalFlags()
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
// скомпилированные шейдеры в cacheDir; если каталога нет — движок
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
// в кэш не пишет, поэтому работал и без mkdirs.
cacheDir.mkdirs()
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir))
try {
created.initialize()
} catch (e: Exception) {
runCatching { created.close() }
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
}
log("llm", "локальный движок инициализирован: ${modelFile.name}")
engine = created
created
}
}
}
@@ -0,0 +1,142 @@
package pw.binom.viewmate.phone.agent
import kotlinx.coroutines.runBlocking
/**
* Медиа-элемент для тул: минимум, что нужно модели.
* Не зависит от моделей lib-core — JVM, тестируемо фейками.
*/
data class MediaItem(
val id: String,
val name: String,
val type: String?,
val year: Int?,
val overview: String?,
val durationSeconds: Long?,
val hasPoster: Boolean,
)
/** Источник медиа для тулсета: поиск + детали элемента. */
interface MediaSource {
/** Поиск по [query]: до [limit] результатов. */
suspend fun search(query: String, limit: Int): List<MediaItem>
/** Детали элемента по [itemId]; null — не найден. */
suspend fun item(itemId: String): MediaItem?
}
/** Ошибка источника: нет пользователя Jellyfin, сеть недоступна и т.п. */
class MediaSourceException(message: String) : Exception(message)
/**
* Выполнить «включить на очках» (тул media_play): проверки + [pw.binom.viewmate.core.phone.PhoneActions.watch]
* + звук на телефоне — уровень приложения. Возвращает человекочитаемый текст.
*/
fun interface MediaPlayer {
suspend fun play(itemId: String, seekMs: Long): String
}
/** Лимит поиска по умолчанию, когда модель его не указала. */
const val MEDIA_SEARCH_DEFAULT_LIMIT = 5
/**
* Определение тулсета media: сигнатуры БЕЗ префикса (механика добавляет media_).
* Описания злые и короткие — маленькая модель (TASK-agent-tools, пункт 10).
*/
val MEDIA_TOOLSET = Toolset(
name = "media",
description = "медиа и воспроизведение: поиск, детали, запуск на очках (search/info/play)",
tools = listOf(
ToolSignature("search", "Поиск фильмов/сериалов по названию. Возвращает: id, название, год, тип.", args = listOf("query", "limit?")),
ToolSignature("info", "Детали элемента по id: название, тип, год, описание, длительность, постер.", args = listOf("id")),
ToolSignature("play", "Включить просмотр на очках: видео скачано на очки, звук — на телефон.", args = listOf("id", "seek_ms?")),
),
)
/**
* Исполнитель тул media: валидация аргументов + форматирование текста.
* Источники: [media] (Jellyfin) и [player] («включить на очках» — уровень приложения).
* Механика вызывает [execute] синхронно — а тулы suspend: мост через runBlocking
* (вызов и так на Dispatchers.IO, из ассистентского цикла).
*/
class MediaToolExecutor(
private val media: MediaSource,
private val player: MediaPlayer,
) : ToolsetExecutor {
private companion object {
const val OVERVIEW_MAX_CHARS = 300
fun exceptionMessage(e: Throwable): String = e.message?.trim()?.takeIf { it.isNotEmpty() } ?: e.toString()
}
override fun execute(toolset: String, tool: String, args: Map<String, String>): String = when (tool) {
"media_search" -> search(args["query"], args["limit"])
"media_info" -> info(args["id"])
"media_play" -> play(args["id"], args["seek_ms"])
else -> "Неизвестный тул \"$tool\". Доступны: media_search, media_info, media_play."
}
/** media_search: строки «id: Название (Год) — Тип», до limit (дефолт [MEDIA_SEARCH_DEFAULT_LIMIT]). */
private fun search(rawQuery: String?, rawLimit: String?): String {
val query = rawQuery?.trim().orEmpty()
if (query.isEmpty()) {
return "media_search: нет аргумента \"query\". Схема: media_search|<query> [limit=<n>]"
}
val limit = rawLimit?.trim()?.toIntOrNull()?.coerceIn(1, 20) ?: MEDIA_SEARCH_DEFAULT_LIMIT
val items: List<MediaItem> = runCatching { runBlocking { media.search(query, limit) } }
.getOrElse { return "Поиск недоступен: ${exceptionMessage(it)}" }
if (items.isEmpty()) return "Ничего не найдено по запросу «$query»."
return items.joinToString("\n") { row ->
val year = row.year?.let { " ($it)" }.orEmpty()
val type = row.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
"${row.id}: ${row.name}$year — $type"
}
}
/** media_info: «Название» — Тип (Год), описание (≤ [OVERVIEW_MAX_CHARS]), длительность, постер. */
private fun info(rawId: String?): String {
val id = rawId?.trim().orEmpty()
if (id.isEmpty()) {
return "media_info: нет аргумента \"id\". Схема: media_info|<id>"
}
val item: MediaItem? = runCatching { runBlocking { media.item(id) } }
.getOrElse { return "Детали недоступны: ${exceptionMessage(it)}" }
if (item == null) return "Элемент \"$id\" не найден."
return infoText(item)
}
/** media_play: id обязателен, seek_ms — опционально (целые миллисекунды); выполняем через [player]. */
private fun play(rawId: String?, rawSeek: String?): String {
val id = rawId?.trim().orEmpty()
if (id.isEmpty()) {
return "media_play: нет аргумента \"id\". Схема: media_play|<id> [seek_ms=<n>]"
}
val seekMs = when (val s = rawSeek?.trim().orEmpty()) {
"" -> 0L
else -> s.toLongOrNull()?.coerceAtLeast(0L)
?: return "media_play: аргумент \"seek_ms\" должен быть числом (миллисекунды)."
}
return runCatching { runBlocking { player.play(id, seekMs) } }
.getOrElse { "Ошибка воспроизведения: ${exceptionMessage(it)}" }
}
private fun infoText(item: MediaItem): String {
val year = item.year?.let { " ($it)" }.orEmpty()
val type = item.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
return buildList {
add("«${item.name}» — ${type}$year")
val overview = item.overview?.trim()
if (!overview.isNullOrBlank()) {
add("Описание: " + if (overview.length > OVERVIEW_MAX_CHARS) overview.take(OVERVIEW_MAX_CHARS).trimEnd() + "…" else overview)
}
item.durationSeconds?.takeIf { it > 0 }?.let { add("Длительность: ${durationText(it)}") }
add("Постер: " + if (item.hasPoster) "есть" else "нет")
}.joinToString("\n")
}
/** Секунды в человекочитаемом виде: ≥ 60 сек — минуты (с секундами в скобках). */
private fun durationText(seconds: Long): String {
val minutes = seconds / 60
return if (minutes > 0) "${minutes} мин. (${seconds} сек.)" else "$seconds сек."
}
}
@@ -44,7 +44,29 @@ data class ChatMessage(
val toolCalls: List<ToolCall>, val toolCalls: List<ToolCall>,
val attachments: List<Attachment>, val attachments: List<Attachment>,
val createdAt: Long, val createdAt: Long,
) ) {
companion object {
/**
* Фабрика для сообщений до записи в БД (тесты, сборка промпта):
* id/sessionId/createdAt — 0, пустые toolCalls/attachments.
*/
fun of(
role: ChatRole,
content: String,
toolCallId: String? = null,
toolCalls: List<ToolCall> = emptyList(),
) = ChatMessage(
id = 0L,
sessionId = 0L,
role = role,
content = content,
toolCallId = toolCallId,
toolCalls = toolCalls,
attachments = emptyList(),
createdAt = 0L,
)
}
}
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */ /** Json для сериализации toolCalls/attachments в строковые колонки БД. */
internal val agentJson: Json = Json { ignoreUnknownKeys = true } internal val agentJson: Json = Json { ignoreUnknownKeys = true }
@@ -0,0 +1,103 @@
package pw.binom.viewmate.phone.agent
import java.io.File
/**
* Скилл = знание: «КАК пользоваться». Триггер (одна строка) + тело (markdown:
* шаги, команды, питфоллы). Пассивный: живёт в файлах, читается по требованию
* через tuл read_skill — содержимое в промпт НЕ попадает (ленивое чтение).
*/
data class Skill(
val name: String,
val trigger: String,
val body: String,
)
/**
* Читает скиллы из папки `files/skills/` (внутренняя files-папка приложения).
* Формат файла:
* ```
* ---
* name: media
* description: поиск фильмов, детали, запуск/управление просмотром.
* ---
* тело скилла (markdown)...
* ```
* Без frontmatter — имя берётся из имени файла, всё содержимое = тело.
* Нетовой папки или пустой каталог — пустой список (механика работает на
* пустом каталоге; реальные скиллы кладутся позже).
*/
class SkillRepository(private val directory: File) {
private val extensions = setOf("md", "markdown", "txt")
fun loadAll(): List<Skill> {
val files = directory.listFiles { f -> f.isFile && f.extension.lowercase() in extensions }
?: return emptyList()
return files
.sortedBy { it.name }
.mapNotNull { parse(it) }
.distinctBy { it.name }
}
private fun parse(file: File): Skill? {
val text = runCatching { file.readText() }.getOrNull() ?: return null
val lines = text.lines()
var name = file.nameWithoutExtension
var trigger = ""
var body = text.trim()
if (lines.isNotEmpty() && lines[0].trim() == "---") {
// Фронтматтер: блок строками key: value между первой и второй строкой ---.
val close = lines.drop(1).indexOfFirst { it.trim() == "---" }
if (close >= 0) {
val fm = lines.subList(1, close + 1)
fmValue(fm, "name")?.let { name = it }
trigger = fmValue(fm, "description") ?: ""
body = lines.drop(close + 2).joinToString("\n").trim()
}
}
return Skill(name = name, trigger = trigger, body = body)
}
/** Значение ключа из frontmatter (`key: значение`); пустое/отсутствующее — null. */
private fun fmValue(fm: List<String>, key: String): String? =
fm.firstOrNull { it.startsWith("$key:") }
?.substringAfter(':')
?.trim()
?.ifBlank { null }
}
/**
* Индекс скиллов (имя + триггер) для промпта; полное содержимое — только
* через [readFull] лениво (вызов read_skill). Индекс идёт в промпт; когда
* связанный тулсет активен, свой скилл (имя = имя тулсета) из индекса УБИРАЕТСЯ.
*/
class SkillRegistry(skills: List<Skill>) {
private val byName: Map<String, Skill> = skills.associateBy { it.name.lowercase() }
/** Все имена (стабильный порядок). */
fun names(): List<String> = byName.values.map { it.name }
fun has(name: String): Boolean = byName.containsKey(name.lowercase())
/**
* Строки индекса «имя — триггер» (одна строка на скилл). [excluded] —
* имена скиллов, которых в индексе не должно быть (например, скилл
* активного тулсета: искушение удалить, а не запретить).
*/
fun index(excluded: Set<String> = emptySet()): List<String> =
byName.values
.filter { !excluded.contains(it.name.lowercase()) }
.sortedBy { it.name }
.map { s -> s.trigger.takeIf { it.isNotBlank() }?.let { "${s.name} — $it" } ?: s.name }
/** Полное содержимое скилла (триггер + тело) — результат read_skill. */
fun readFull(name: String): String? =
byName[name.lowercase()]?.let { s ->
buildString {
append("Скилл \"").append(s.name).append("\"\n")
if (s.trigger.isNotBlank()) append("Триггер: ").append(s.trigger).append('\n')
append("\n")
append(s.body)
}
}
}
@@ -0,0 +1,191 @@
package pw.binom.viewmate.phone.agent
import java.util.concurrent.ConcurrentHashMap
/**
* Тулсет = набор тул (активные руки, «ЧТО МОЖНО»). Все тулы набора несут
* префикс ИМЕНИ набора: media_search, media_info — префикс = маршрутизация
* (система видит media_* → знает набор и сигнатуры).
*
* Аргументы сигнатуры описываются ИМЕНАМИ: «query» — обязательный,
* «seek_ms?» — опциональный («?»). Описание тула в промпте одной строкой:
* имя + что делает + аргументы — компактно для маленькой модели.
*/
data class ToolSignature(
val name: String,
val description: String,
val args: List<String> = emptyList(),
) {
/** Обязательные аргументы (без маркера «?»). */
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
fun argNames(): List<String> = args.map { it.substringBefore('?') }
}
/**
* Набор тул: имя (одновременно префикс), описание для каталога, список тул
* (сигнатуры БЕЗ префикса — префикс добавляется при маршрутизации).
*/
data class Toolset(
val name: String,
val description: String,
val tools: List<ToolSignature>,
) {
/** Полное имя тула (с префиксом): media + search → media_search. */
fun toolFull(signature: ToolSignature): String = "${name}_${signature.name}"
/** Найти сигнатуру по полному имени (media_search → search). */
fun signatureOf(fullName: String): ToolSignature? =
tools.firstOrNull { toolFull(it) == fullName }
/** Строка каталога: «media — управление медиа и воспроизведением (search/info/play)». */
fun catalogLine(): String = "$name — $description"
/** Строки описаний тул для промпта (секция «Активные тулсеты»). */
fun toolLines(): List<String> = tools.map { s ->
val argsPart = if (s.args.isEmpty()) "" else "|" + s.args.joinToString(",")
"${name}_${s.name}$argsPart — ${s.description}"
}
}
/** Исполнитель тул набора: (набор, тул-имя (с префиксом), аргументы) → строка результата. */
fun interface ToolsetExecutor {
fun execute(toolset: String, tool: String, args: Map<String, String>): String
}
/** Результат enable_toolset. */
sealed class EnableResult {
/** Активен (при повторном вызове уже активен — таймаут продлён). */
class Activated(val toolset: String, val alreadyActive: Boolean) : EnableResult()
/** Неизвестное имя: сообщение для модели («набора X нет, доступны: ...»). */
class Unknown(val name: String, val message: String) : EnableResult()
}
/** Результат disable_toolset — всегда не ошибка (идемпотентно и прощающе). */
sealed class DisableResult {
class Disabled(val toolset: String) : DisableResult()
/** Набор не был активен — информационно, не ошибка. */
class NotActive(val message: String) : DisableResult()
}
/**
* Реестр тулсетов + активность НА СЕССИЮ (сессия → набор → deadline).
*
* - Не персистится: перезапуск приложения = всё сброшено.
* - Таймаут: [DEFAULT_TIMEOUT_MS] (10 минут) с последнего вызова тулы набора;
* enable и каждый успешный вызов [touch] продлевают.
* - Проверка ТОЛЬКО при сборке промпта / диспаче — никаких фоновых таймеров:
* просроченные записи чистятся лениво в [activeNames]/[isActive].
*
* Часы и таймаут инжектируемые — юнит-тесты не ждут реальные 10 минут.
*/
class ToolsetRegistry(
val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
private val clock: () -> Long = { System.currentTimeMillis() },
) {
companion object {
/** Дефолтный таймаут активности: 10 минут. */
const val DEFAULT_TIMEOUT_MS = 10 * 60_000L
}
private val toolsets = LinkedHashMap<String, Toolset>()
private val executors = HashMap<String, ToolsetExecutor>()
/** сессия → (набор → deadline, epoch-мс). Только в памяти. */
private val active = ConcurrentHashMap<Long, ConcurrentHashMap<String, Long>>()
/** Зарегистрировать набор (опционально с исполнителем его тул). */
fun register(toolset: Toolset, executor: ToolsetExecutor? = null) {
synchronized(this) {
toolsets[toolset.name] = toolset
if (executor != null) executors[toolset.name] = executor
}
}
/** Имена всех наборов каталога. */
fun names(): List<String> = synchronized(this) { toolsets.keys.toList() }
fun toolset(name: String): Toolset? = synchronized(this) { toolsets[name] }
/**
* Префиксная маршрутизация: имя тула с префиксом набора (media_search → media).
* Префикс = «имени_».
*/
fun findByPrefix(fullToolName: String): Toolset? =
synchronized(this) {
toolsets.entries.firstOrNull { (n, _) -> fullToolName.startsWith(n + "_") }?.value
}
fun executor(name: String): ToolsetExecutor? = synchronized(this) { executors[name] }
/** Строки каталога: «media — ...» (по одной строке на набор). */
fun catalogLines(): List<String> = synchronized(this) { toolsets.values.map { it.catalogLine() } }
/**
* Активные наборы сессии (deadline > now). Просроченные записи чистятся
* здесь же (ленивая проверка, нет фона). Порядок = порядок каталога.
*/
fun activeNames(sessionId: Long): List<String> =
synchronized(this) {
val session = active[sessionId] ?: return emptyList()
val now = clock()
val expired = session.entries.filter { it.value <= now }.map { it.key }
expired.forEach { session.remove(it) }
if (session.isEmpty()) active.remove(sessionId)
toolsets.keys.filter { session.containsKey(it) }
}
/** Активен ли набор в сессии (с очисткой просроченных). */
fun isActive(sessionId: Long, name: String): Boolean = activeNames(sessionId).contains(name)
/** Сигнатура тула, если тул известного набора (для валидации аргументов). */
fun signatureOf(fullToolName: String): ToolSignature? =
findByPrefix(fullToolName)?.signatureOf(fullToolName)
/**
* Активировать набор в сессии (идемпотентно: повторный enable = продление
* таймаута, не ошибка). Deadline = now + timeout.
*/
fun enable(sessionId: Long, name: String): EnableResult {
val ts = toolset(name) ?: return EnableResult.Unknown(
name = name,
message = "Набора \"$name\" нет. " + when {
toolsets.isNotEmpty() -> "Доступны: " + names().joinToString(", ")
else -> "Доступных тулсетов нет."
},
)
synchronized(this) {
val session = active.computeIfAbsent(sessionId) { ConcurrentHashMap() }
val already = session.containsKey(ts.name)
session[ts.name] = clock() + timeoutMs
return EnableResult.Activated(ts.name, already)
}
}
/**
* Снять набор в сессии (идемпотентно: «нет активных наборов» — не ошибка).
*/
fun disable(sessionId: Long, name: String): DisableResult {
synchronized(this) {
val session = active[sessionId]
if (session != null && session.remove(name) != null) return DisableResult.Disabled(name)
val anyActive = if (session != null && session.isNotEmpty()) {
val now = clock()
session.entries.removeAll { it.value <= now }
session.isNotEmpty()
} else {
false
}
val msg = if (anyActive) "Тулсет \"$name\" не активен." else "Нет активных тулсетов."
return DisableResult.NotActive(msg)
}
}
/** Продлить таймаут (вызывается после успешного выполнения тулы набора). */
fun touch(sessionId: Long, name: String) {
synchronized(this) {
val session = active[sessionId] ?: return
if (session.containsKey(name)) session[name] = clock() + timeoutMs
}
}
}
@@ -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 package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig import java.io.File
import com.k2fsa.sherpa.onnx.Vad import java.io.FileOutputStream
import com.k2fsa.sherpa.onnx.VadModelConfig
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import kotlin.math.sqrt
import pw.binom.viewmate.phone.log
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */ /** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
internal fun s16leToFloat(pcm: ByteArray): FloatArray { internal fun s16leToFloat(pcm: ByteArray): FloatArray {
@@ -26,28 +26,73 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
} }
/** /**
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) → * Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
* Whisper (в отдельном потоке) → колбэк [onPhrase]. * Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
*
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
* Чистая функция (без Android) — покрыта JVM-тестами.
*/
internal fun trimEdges(
samples: FloatArray,
threshold: Float = 0.01f,
windowSize: Int = 320,
padWindows: Int = 15,
): FloatArray {
if (samples.isEmpty()) return samples
var first = -1
var lastEnd = -1
var idx = 0
while (idx < samples.size) {
val end = minOf(idx + windowSize, samples.size)
var sum = 0.0
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
val rms = sqrt(sum / (end - idx)).toFloat()
if (rms >= threshold) {
if (first == -1) first = idx
lastEnd = end
}
idx = end
}
if (first == -1) return FloatArray(0)
val pad = padWindows * windowSize
val start = maxOf(0, first - pad)
val stop = minOf(samples.size, lastEnd + pad)
return samples.copyOfRange(start, stop)
}
/**
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
* [PhraseRecognizer] с предварительным обрезанием краевой тишины ([trimEdges]).
* *
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание * Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
* фраз идёт в однопоточном executor-е (не блокирует приём аудио). * идёт в однопоточном executor-е (не блокирует приём аудио).
* *
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s] * Тишина [SILENCE_TIMEOUT_MS] (с момента первого [accept]) → автo-финиш:
* (автo-отмена, вызывается один раз за сессию). * накопленный буфер распознаётся (как [finish]) и результат отдаётся в
* [onAutoFinished], затем сессия сбрасывается и вызывается [onSilence30s]
* (автo-отмена, один раз за сессию).
*/ */
class SttStreamer( class SttStreamer(
private val stt: WhisperStt, private val stt: PhraseRecognizer,
private val vadModelPath: String,
private val onPhrase: (phrase: String, full: String) -> Unit,
private val onSilence30s: () -> Unit, private val onSilence30s: () -> Unit,
/** Полный текст сессии, распознанный автo-финишем по тишине (см. [onSilence30s]). */
private val onAutoFinished: (full: String) -> Unit,
private val dumpPcm: File? = null,
) { ) {
companion object { companion object {
private const val SILENCE_TIMEOUT_MS = 30_000L private const val SILENCE_TIMEOUT_MS = 30_000L
private const val WINDOW_SIZE = 512
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
private const val SAMPLE_RATE = 16_000
/** Максимум PCM для распознавания: 120 с (дольше — последние 120 с). */
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 120
} }
private val lock = java.util.concurrent.locks.ReentrantLock() private val lock = java.util.concurrent.locks.ReentrantLock()
private val vad: Vad = Vad(null, vadConfig())
private val executor = Executors.newSingleThreadExecutor { r -> private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "stt-recognize").apply { isDaemon = true } Thread(r, "stt-recognize").apply { isDaemon = true }
} }
@@ -64,22 +109,40 @@ class SttStreamer(
@Volatile @Volatile
private var deadlineMs = 0L private var deadlineMs = 0L
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */ /**
private var pending = FloatArray(0) * 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()
}
/** Весь PCM сессии (FloatArray @16 кГц): [finish] прогоняет его через распознаватель одним вызовом. */
private var sessionPcm: FloatArray = FloatArray(0)
/** Последняя поставленная в executor задача — finish ждёт её завершения. */ /** Последняя поставленная в executor задача — finish ждёт её завершения. */
private var lastFuture: Future<*>? = null private var lastFuture: java.util.concurrent.Future<*>? = null
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) { private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
while (true) { while (true) {
val deadline = deadlineMs val deadline = deadlineMs
if (deadline > 0 && System.currentTimeMillis() > deadline) { if (deadline > 0 && System.currentTimeMillis() > deadline) {
lock.withLock { // Снимаем срок и освобождаем lock до распознавания — watchdog не держит
if (deadlineMs == deadline) { // lock при вызове finish (иначе блокировка-в-блокировке c accept).
val expired = lock.withLock {
if (deadlineMs != deadline) return@withLock false
deadlineMs = 0 deadlineMs = 0
resetLocked() true
onSilence30s()
} }
if (expired) {
val fullText = finish()
if (fullText.isNotBlank()) onAutoFinished(fullText)
lock.withLock { resetLocked() }
onSilence30s()
} }
} }
runCatching { Thread.sleep(1_000) }.onFailure { return@thread } runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
@@ -91,110 +154,80 @@ class SttStreamer(
lock.withLock { lock.withLock {
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
val floats = s16leToFloat(pcm) val floats = s16leToFloat(pcm)
var i = 0 sessionPcm = sessionPcm + floats
if (pending.isNotEmpty()) {
val need = WINDOW_SIZE - pending.size
val take = minOf(need, floats.size)
val combined = pending + floats.copyOfRange(0, take)
i += take
if (combined.size == WINDOW_SIZE) {
feedWindow(combined)
pending = FloatArray(0)
} else {
pending = combined
return
} }
} val out = dumpOut
if (out != null) {
while (i + WINDOW_SIZE <= floats.size) { runCatching { out.write(pcm) }
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE)) .onFailure { log("stt", "PCM-дампа: ${it.message}") }
i += WINDOW_SIZE
}
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
} }
} }
/** /**
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех * Завершить сессию (клик): распознать весь буфер сессии одним вызовом
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после * (хвост [MAX_FULL_BUFFER_SAMPLES], если длиннее) с обрезанием краевой тишины
* вызова нужно [reset] (состояние сессии снимает вызывающий). * — [full] перезаписывается результатом. Вернуть полный текст.
* Буферы НЕ сбрасывает — после вызова нужен [reset] (состояние сессии
* снимает вызывающий).
*/ */
fun finish(): String { fun finish(): String {
lock.withLock { lock.withLock {
vad.flush() recognizeFullBufferLocked()
while (!vad.empty()) {
val seg = vad.front()
recognizeSegment(seg.samples)
vad.pop()
}
val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
return full return full
} }
} }
/**
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком,
* если ≤ [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES]
* сэмплов (команда перед кликом точно влезает). Перед распознаванием
* применяется [trimEdges]. Результат перезаписывает [full].
* Вызывать с удержанным [lock].
*/
private fun recognizeFullBufferLocked() {
val buffer = sessionPcm
if (buffer.isEmpty()) return
val base = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
} else buffer
val chunk = trimEdges(base)
if (chunk.isEmpty()) return
val sessionId = session
val task = executor.submit {
if (sessionId != session) return@submit
val result = runCatching { stt.recognize(chunk) }
if (result.isSuccess) {
full = result.getOrThrow()
log(
"stt",
"полный буфер: ${chunk.size} сэмплов (${chunk.size / SAMPLE_RATE.toFloat()}с) → '$full'",
)
} else {
log("stt", "полный буфер: ошибка распознавания: ${result.exceptionOrNull()?.message}")
}
}
lastFuture = task
runCatching { task.get(30, TimeUnit.SECONDS) }
}
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */ /** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
fun reset() { fun reset() {
lock.withLock { resetLocked() } lock.withLock { resetLocked() }
} }
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */ /** Освободить ресурсы и сторожевой поток (при выключении приложения). */
fun close() { fun close() {
lock.withLock { lock.withLock { deadlineMs = 0 }
deadlineMs = 0 runCatching { dumpOut?.close() }
runCatching { vad.release() } .onFailure { log("stt", "PCM-дампа: ${it.message}") }
}
executor.shutdownNow() executor.shutdownNow()
watchdog.interrupt() watchdog.interrupt()
} }
private fun resetLocked() { private fun resetLocked() {
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
runCatching { vad.flush() }
while (!vad.empty()) vad.pop()
session++ session++
full = "" full = ""
pending = FloatArray(0) sessionPcm = FloatArray(0)
deadlineMs = 0 deadlineMs = 0
} }
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
private fun feedWindow(window: FloatArray) {
vad.acceptWaveform(window)
while (!vad.empty()) {
val seg = vad.front()
// Копия: массив из нативного VAD может переиспользоваться после pop.
recognizeSegment(seg.samples.copyOf())
vad.pop()
}
}
/** Распознать сегмент в executor-е: обновить full и позвать onPhrase. */
private fun recognizeSegment(samples: FloatArray) {
val sessionId = session
lastFuture = executor.submit {
if (sessionId != session) return@submit
val phrase = runCatching { stt.recognize(samples) }.getOrDefault("")
if (phrase.isBlank()) return@submit
val newFull = if (full.isEmpty()) phrase else "$full $phrase"
full = newFull
deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
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 tokensPath: String,
private val numThreads: Int = 4, private val numThreads: Int = 4,
private val language: String = "ru", private val language: String = "ru",
) { ) : PhraseRecognizer {
private val recognizer: OfflineRecognizer = OfflineRecognizer( private val recognizer: OfflineRecognizer = OfflineRecognizer(
null, null,
OfflineRecognizerConfig( OfflineRecognizerConfig(
@@ -33,7 +33,7 @@ class WhisperStt(
) )
/** Распознать 16 кГц mono PCM. Возвращает текст. */ /** Распознать 16 кГц mono PCM. Возвращает текст. */
fun recognize(samples: FloatArray): String { override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream() val stream = recognizer.createStream()
try { try {
stream.acceptWaveform(samples, 16000) stream.acceptWaveform(samples, 16000)
@@ -1,35 +1,241 @@
package pw.binom.viewmate.phone.ui package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.Session
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */ /**
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
* локального ассистента (та же очередь, что и голос с очков). Управление
* сессиями: новая, переключение (выпадающий список), сброс текущей.
*/
@Composable @Composable
fun ChatScreen() { fun ChatScreen() {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) }
var input by remember { mutableStateOf("") }
var sessionMenu by remember { mutableStateOf(false) }
var activeId by remember { mutableStateOf<Long?>(null) }
val listState = rememberLazyListState()
// Загрузка списка сессий и сообщений при старте и при смене активной.
LaunchedEffect(activeId) {
val dao = app.chatDao
val (sess, msgs) = withContext(Dispatchers.IO) {
val all = dao.listSessions()
val target = activeId ?: all.firstOrNull()?.id
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
all to history.map { it.role to it.content }
}
sessions = sess
activeId = activeId ?: sess.firstOrNull()?.id
messages = msgs
}
// Подписка на смену активной сессии извне (кнопки в PhoneApp).
val externalActive by app.activeSessionId.collectAsState()
LaunchedEffect(externalActive) {
if (externalActive != null && externalActive != activeId) {
activeId = externalActive
}
}
// Автопрокрутка вниз при новых сообщениях.
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
}
// Периодическая перезагрузка сообщений из БД (ответ ассистента приходит
// асинхронно — лента должна подхватывать его без ручного обновления)
// + списка сессий (автоименование: имя проставляется при первом сообщении).
LaunchedEffect(activeId) {
while (true) {
delay(2_000)
val id = activeId
if (id != null) {
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } }
if (fresh != messages) messages = fresh
}
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
}
}
Column( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxSize()
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, .imePadding()
.padding(12.dp),
) { ) {
// Шапка: название + управление сессиями
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "Чат с Порфирием", text = "Порфирий",
color = PhoneText, color = PhoneText,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
) )
// Переключение сессий
Box {
TextButton(onClick = { sessionMenu = true }) {
Text( Text(
text = "в разработке (следующий этап)", text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
color = PhoneAccent,
fontSize = 12.sp,
)
}
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s ->
DropdownMenuItem(
text = { Text(s.title, fontSize = 12.sp) },
onClick = {
activeId = s.id
app.selectChatSession(s.id)
sessionMenu = false
},
)
}
DropdownMenuItem(
text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) },
onClick = {
activeId = app.newChatSession()
sessionMenu = false
},
)
}
}
// Сброс текущей
TextButton(onClick = {
app.resetChatSession()
activeId?.let { id ->
val history = app.chatDao.messagesForSession(id).map { it.role to it.content }
messages = history
}
}) {
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
}
}
Spacer(Modifier.height(2.dp))
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
val llmChoice by app.llmChoice.collectAsState()
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
color = PhoneMuted,
fontSize = 10.sp,
)
Spacer(Modifier.height(4.dp))
// Лента сообщений
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (messages.isEmpty()) {
Text(
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
color = PhoneMuted, color = PhoneMuted,
fontSize = 13.sp, fontSize = 13.sp,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(messages) { (role, content) ->
MessageBubble(role = role, text = content)
}
}
}
}
Spacer(Modifier.height(8.dp))
// Ввод
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Сообщение…", fontSize = 14.sp, color = PhoneMuted) },
textStyle = MaterialTheme.typography.bodyMedium.copy(color = PhoneText),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val text = input.trim()
if (text.isEmpty()) return@Button
input = ""
messages = messages + (ChatRole.USER to text)
app.sendToAssistant(text)
},
) {
Text("➤", fontSize = 16.sp)
}
}
}
}
/** Пузырь сообщения: user — справа акцентный, assistant — слева карточка. */
@Composable
private fun MessageBubble(role: ChatRole, text: String) {
val isUser = role == ChatRole.USER
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Text(
text = text,
color = PhoneText,
fontSize = 14.sp,
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp)
.background(
color = if (isUser) PhoneSurface else PhoneCard,
shape = RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 6.dp),
) )
} }
} }
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -24,6 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.Gemma4E2B
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import kotlin.math.roundToLong import kotlin.math.roundToLong
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */ /** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
@@ -137,6 +141,10 @@ fun GlassesInfoScreen(
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp))
LlmModelSection()
} }
} }
@@ -165,3 +173,118 @@ private fun InfoRow(label: String, value: String) {
) )
} }
} }
/**
* Блок выбора модели LLM ассистента: серверная (llm.binom.pw) или локальная
* Gemma 4 E2B (LiteRT-LM) + управление скачиванием модели (прогресс, отмена).
*/
@Composable
private fun LlmModelSection() {
val app = PhoneApp.instance
val choice by app.llmChoice.collectAsState()
val llmState by app.localLlmState.collectAsState()
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("Модель LLM ассистента")
StatusBlock {
Row(verticalAlignment = Alignment.CenterVertically) {
LlmChoiceChip(
label = "сервер",
selected = choice == LlmModelChoice.REMOTE,
onClick = { app.setLlmModelChoice(LlmModelChoice.REMOTE) },
)
Spacer(Modifier.width(8.dp))
LlmChoiceChip(
label = "локальная",
selected = choice == LlmModelChoice.LOCAL,
onClick = { app.setLlmModelChoice(LlmModelChoice.LOCAL) },
)
}
Spacer(Modifier.height(8.dp))
if (choice == LlmModelChoice.LOCAL) {
when (val s = llmState) {
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
}
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
ProgressBar(
percent = s.percent,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
}
Spacer(Modifier.height(4.dp))
Text(
text = "Скачивание модели… ${s.percent}%",
color = PhoneMuted,
fontSize = 10.sp,
)
}
LocalLlmModelState.Ready -> Text(
text = "Модель готова (Gemma 4 E2B, локально)",
color = PhoneGreen,
fontSize = 11.sp,
)
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Ошибка скачивания: ${s.message}",
color = PhoneRed,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
}
}
} else {
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
}
}
}
}
/** Чип переключателя выбора модели (активный — акцентный фон). */
@Composable
private fun LlmChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(if (selected) PhoneAccent else PhoneSurface)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = if (selected) PhoneBackground else PhoneTextDim,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
/** Маленькая текстовая кнопка (скачать/отмена/повтор) в блоке LLM. */
@Composable
private fun LlmSmallAction(label: String, onClick: () -> Unit) {
Text(
text = label,
color = PhoneAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
@@ -71,6 +71,9 @@ fun GlassesScreen(
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании // масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
var scale by remember { mutableStateOf(app.state.scale) } var scale by remember { mutableStateOf(app.state.scale) }
// высота экрана очков (0..1): 1 — у верха, 0 — нижняя кромка у низа
var vertical by remember { mutableStateOf(app.state.vertical) }
val actions = app.actions val actions = app.actions
// активное скачивание на очки: первое с незавершённым файлом // активное скачивание на очки: первое с незавершённым файлом
@@ -183,6 +186,31 @@ fun GlassesScreen(
) )
} }
// высота экрана очков: 1.0 — у верха (как сейчас), 0.0 — нижняя кромка у низа (с учётом масштаба)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
) {
Text(
text = "Высота: ${"%.2f".format(vertical)}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
Slider(
value = vertical,
onValueChange = { vertical = it },
onValueChangeFinished = {
app.state.vertical = vertical
val a = actions
if (a != null) scope.launch { a.applyVertical(vertical) }
},
valueRange = 0f..1f,
steps = 9, // шаг 0.1
)
}
// активная загрузка контента на очки // активная загрузка контента на очки
if (activeGlassesItem != null) { if (activeGlassesItem != null) {
Text( Text(
@@ -360,6 +388,9 @@ private fun GlassesStatusCard(
fg = if (playing) PhoneBlueText else PhoneMuted, fg = if (playing) PhoneBlueText else PhoneMuted,
) )
} }
// Полевое испытание либы rayneo-mercury (BLE-протокол очков)
MercuryPanel()
} }
} }
@@ -0,0 +1,124 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.viewmate.phone.MercuryBridge
/**
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
* Минимум: найти очки → подключить → погасить/включить экран (deepSuspend).
*/
@Composable
fun MercuryPanel() {
val scope = rememberCoroutineScope()
val state by MercuryBridge.state.collectAsState()
val busy = false
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.clip(RoundedCornerShape(14.dp))
.background(PhoneCard)
.padding(12.dp),
) {
Text(
text = "🛰 Mercury BLE (полевой тест)",
color = PhoneText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = state.statusText,
color = if (state.lastError != null) PhoneRedText else PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(top = 4.dp),
)
if (state.connected) {
Text(
text = "очки: ${state.glassesName ?: "?"} · BLE готов",
color = PhoneBlueText,
fontSize = 11.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
Row(modifier = Modifier.padding(top = 10.dp)) {
Button(
onClick = {
scope.launch {
MercuryBridge.connectFirst()
}
},
enabled = !busy,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("подключить очки", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
scope.launch { MercuryBridge.disconnect() }
},
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text("отключить", fontSize = 12.sp)
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = {
scope.launch { MercuryBridge.screenOff() }
},
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2B2F36),
contentColor = Color(0xFF9AA0A6),
),
) {
Text("🌙 погасить экран", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
scope.launch { MercuryBridge.screenOn() }
},
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneAccent,
contentColor = Color(0xFF10251A),
),
) {
Text("☀️ включить экран", fontSize = 12.sp)
}
}
}
}
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.plugins.websocket.webSocket
import io.ktor.server.testing.testApplication
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import io.ktor.websocket.readText import io.ktor.websocket.readText
import io.ktor.websocket.send import io.ktor.websocket.send
@@ -23,24 +22,49 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Hello import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.SetMode 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.Welcome
import pw.binom.viewmate.core.protocol.protocolJson import pw.binom.viewmate.core.protocol.protocolJson
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WavPcm
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.fail import kotlin.test.fail
/**
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
*/
class GlassesServerTest { class GlassesServerTest {
private val json = protocolJson private val json = protocolJson
@Test /** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
fun helloGetsWelcome() = testApplication { private fun startServer(): Pair<GlassesServer, Int> {
val hub = GlassesHub(PhoneState()) val port = freePort()
application { glassesServerModule(hub) } val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
val client = createClient { install(WebSockets) } server.start()
return server to port
}
client.webSocket(GLASSES_WS_PATH) { private fun freePort(): Int {
val s = java.net.ServerSocket(0)
val p = s.localPort
s.close()
return p
}
@Test
fun helloGetsWelcome() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0"))) send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
val frame = incoming.receive() as Frame.Text val frame = incoming.receive() as Frame.Text
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
@@ -50,12 +74,9 @@ class GlassesServerTest {
} }
@Test @Test
fun gestureIsProcessed() = testApplication { fun gestureIsProcessed() = runServer { port, hub ->
val hub = GlassesHub(PhoneState()) val client = wsClient()
application { glassesServerModule(hub) } client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick"))) send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0))) send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
} }
@@ -64,28 +85,22 @@ class GlassesServerTest {
} }
@Test @Test
fun connectedCounterTracksSessions() = testApplication { fun connectedCounterTracksSessions() = runServer { port, hub ->
val hub = GlassesHub(PhoneState()) val client = wsClient()
application { glassesServerModule(hub) } client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) { }
awaitTrue { hub.connected.value == 0 } awaitTrue { hub.connected.value == 0 }
} }
@Test @Test
fun broadcastReachesAllGlasses() = testApplication { fun broadcastReachesAllGlasses() = runServer { port, hub ->
val hub = GlassesHub(PhoneState()) val client = wsClient()
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
coroutineScope { coroutineScope {
val opened = Channel<Unit>(2) val opened = Channel<Unit>(2)
val received = Channel<HostToGlasses>(2) val received = Channel<HostToGlasses>(2)
val jobs = (1..2).map { val jobs = (1..2).map {
launch { launch {
client.webSocket(GLASSES_WS_PATH) { client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
opened.send(Unit) opened.send(Unit)
while (true) { while (true) {
val frame = incoming.receive() val frame = incoming.receive()
@@ -115,12 +130,9 @@ class GlassesServerTest {
} }
@Test @Test
fun downloadProgressUpdatesHubPerFileMap() = testApplication { fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
val hub = GlassesHub(PhoneState()) val client = wsClient()
application { glassesServerModule(hub) } client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING))) send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE))) send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
} }
@@ -131,12 +143,9 @@ class GlassesServerTest {
} }
@Test @Test
fun extendedGlassesStatusUpdatesHubFields() = testApplication { fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
val hub = GlassesHub(PhoneState()) val client = wsClient()
application { glassesServerModule(hub) } client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send( send(
json.encodeToString( json.encodeToString(
GlassesToHost.serializer(), GlassesToHost.serializer(),
@@ -163,6 +172,81 @@ class GlassesServerTest {
assertEquals("0.2.0", hub.appVersion.value) 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): весь принятый PCM
* распознаётся одним вызовом на буфер сессии, фейк-распознаватель
* даёт «привет» → очки получают SttDone с непустым текстом.
*/
@Test
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
hub.sttFactory = {
SttStreamer(
stt = FakePhraseRecognizer(),
onSilence30s = { },
onAutoFinished = { },
)
}
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) {
val (server, port) = startServer()
try {
kotlinx.coroutines.runBlocking { block(port, server.hub) }
} finally {
server.stop()
}
}
private fun wsClient() = io.ktor.client.HttpClient {
install(WebSockets)
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) { private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) { while (!condition()) {
@@ -0,0 +1,308 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Механика агента: три состояния промпта, бесшовная подмена, валидация
* аргументов, невидимые тулы, таймаут (инжект часов), по-сессийность.
*/
class AgentToolkitTest {
private var now = 10_000_000L
private class Fixture(val toolkit: AgentToolkit, val calls: MutableList<String>, val shown: MutableList<String>)
private fun fixture(withExecutor: Boolean = true): Fixture {
now = 10_000_000L
val calls = mutableListOf<String>()
val shown = mutableListOf<String>()
val registry = ToolsetRegistry(timeoutMs = ToolsetRegistry.DEFAULT_TIMEOUT_MS, clock = { now })
val ts = Toolset(
name = "media",
description = "Управление медиа и воспроизведением (search/info/play)",
tools = listOf(
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
ToolSignature("info", "Детали фильма.", args = listOf("id")),
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
),
)
if (withExecutor) {
registry.register(ts) { tset, tool, args ->
calls.add("$tset.$tool ${args.entries.joinToString { "${'{' }${it.key}=${it.value}${'}'}" }}")
"Результат $tool: ${args.entries.joinToString { "${it.key}=${it.value}" }}"
}
} else {
registry.register(ts, null)
}
val skills = SkillRegistry(
listOf(
Skill("media", "Поиск фильмов, детали, запуск/управление.", "ТЕЛО: media_search|query=..."),
Skill("web", "Как сделать веб-поиск.", "Шаги веб-поиска..."),
),
)
return Fixture(
toolkit = AgentToolkit(skills = skills, toolsets = registry, showText = { shown.add(it) }),
calls = calls,
shown = shown,
)
}
private fun run(d: AgentToolkit.Dispatch): String =
(d as AgentToolkit.Dispatch.Run).result
// ---------- три состояния промпта ----------
@Test
fun `состояние 1 нет активных тулсетов, каталог и скиллы`() {
val p = fixture().toolkit.systemPrompt(1)
assertTrue(p.contains("Ядро:"))
assertTrue(p.contains("get_current_time"))
assertTrue(p.contains("show_text|text"))
assertTrue(p.contains("enable_toolset|имя"))
assertTrue(p.contains("Каталог тулсетов"))
assertTrue(p.contains("media — Управление медиа и воспроизведением"))
// скиллы: по строке на каждый
assertTrue(p.contains("media — Поиск фильмов, детали, запуск/управление."))
assertTrue(p.contains("web — Как сделать веб-поиск."))
// описания тул набора ещё не раскрыты (набор не активен)
assertFalse(p.contains("Активные тулсеты"))
assertFalse(p.contains("media_search|query"))
}
@Test
fun `enable невидимый, промпт пересобирается, состояние 3`() {
val f = fixture()
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(d is AgentToolkit.Dispatch.Silent)
val p = f.toolkit.systemPrompt(1)
assertTrue(p.contains("Активные тулсеты: media"))
assertTrue(p.contains("media_search|query — Поиск фильмов"))
assertTrue(p.contains("media_play|id,seek_ms? — Запуск/управление"))
// связанный скилл убран из промпта (имя скилла = имя тулсета)
assertFalse(p.contains("media — Поиск фильмов"))
// остальные скиллы на месте
assertTrue(p.contains("web — Как сделать веб-поиск."))
}
@Test
fun `повторный enable, напоминание, уже активен`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val d2 = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
val p1 = f.toolkit.systemPrompt(1)
assertTrue(p1.contains("Тулсет \"media\" уже активен, таймаут продлён"))
// напоминание одноразовое: следующий промпт без него
val p2 = f.toolkit.systemPrompt(1)
assertFalse(p2.contains("уже активен"))
}
@Test
fun `disable обратно в состояние 1, двойной disable не ошибка`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
val p1 = f.toolkit.systemPrompt(1)
assertFalse(p1.contains("Активные тулсеты"))
assertTrue(p1.contains("media — Поиск фильмов")) // скилл вернулся в индекс
val d2 = f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
val p2 = f.toolkit.systemPrompt(1)
assertTrue(p2.contains("Нет активных тулсетов"))
}
@Test
fun `неизвестный набор, напоминание с доступными в промпте`() {
val f = fixture()
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "other"))
assertTrue(d is AgentToolkit.Dispatch.Silent)
val p = f.toolkit.systemPrompt(1)
assertTrue(p.contains("Набора \"other\" нет"))
assertTrue(p.contains("media"))
}
// ---------- бесшовная подмена ----------
@Test
fun `подмена, аргументы не совпадают, вызов явно не выполнялся`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
assertTrue(msg.contains("Тулсет media активирован"))
assertTrue(msg.contains("Реестр:"))
assertTrue(msg.contains("media_search"))
assertTrue(msg.contains("ещё НЕ выполнялся"))
assertTrue(msg.contains("вызови media_search с аргументами: query"))
// тул не выполнялся реально
assertTrue(f.calls.isEmpty())
// набор стал активен (подмена = enable)
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
}
@Test
fun `подмена, валидные аргументы, выполнение без второго круга`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "Рассел")))
assertTrue(msg.contains("Результат media_search"))
assertEquals(1, f.calls.size)
assertTrue(f.calls.single().contains("media.media_search"))
assertTrue(f.calls.single().contains("query=Рассел"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
}
// ---------- валидация и маршрутизация ----------
@Test
fun `активный тулсет, не хватает аргументов, ошибка со схемой`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
assertTrue(msg.contains("аргументы неверны"))
assertTrue(msg.contains("query"))
assertTrue(msg.contains("Схема: media_search|query"))
}
@Test
fun `маршрутизация по префкису в набор, вызов идёт в executor`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_info", mapOf("id" to "123")))
assertTrue(msg.contains("Результат media_info"))
assertTrue(f.calls.single().contains("media.media_info"))
assertTrue(f.calls.single().contains("id=123"))
}
@Test
fun `тул не входит в сигнатуры набора`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_bogus", emptyMap()))
assertTrue(msg.contains("Тул \"media_bogus\" в тулсете \"media\" нет"))
}
@Test
fun `неизвестный тул, ответ со списком доступных`() {
val msg = run(fixture().toolkit.dispatch(1, "frobo", emptyMap()))
assertTrue(msg.contains("Тул \"frobo\" не найден"))
assertTrue(msg.contains("enable_toolset"))
assertTrue(msg.contains("media"))
}
@Test
fun `активный тулсет без исполнителя, понятное сообщение`() {
val f = fixture(withExecutor = false)
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "x")))
assertTrue(msg.contains("ещё не реализованы"))
}
// ---------- ядро тулов ----------
@Test
fun `read_skill, весь текст скилла`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "media")))
assertTrue(msg.contains("ТЕЛО: media_search|query=..."))
}
@Test
fun `read_skill неизвестный скилл, прощающая подсказка`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "неттакого")))
assertTrue(msg.contains("Скилл \"неттакого\" не найден"))
assertTrue(msg.contains("media"))
assertTrue(msg.contains("web"))
}
@Test
fun `show_text, текст шлётся в шир, результат и пропуск без дисплея`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "show_text", mapOf("text" to "Привет!")))
assertEquals("Привет!", f.shown.single())
assertTrue(msg.contains("Показано на очках: Привет!"))
val bad = run(f.toolkit.dispatch(1, "show_text", emptyMap()))
assertTrue(bad.contains("\"text\""))
// без подключённого дисплея (showText = null) — понятный результат, не падение
val bare = AgentToolkit(SkillRegistry(emptyList()), ToolsetRegistry(clock = { now }))
val disconnected = run(bare.dispatch(1, "show_text", mapOf("text" to "X")))
assertTrue(disconnected.contains("не показан"))
}
@Test
fun `get_current_time, время в ISO`() {
val msg = run(fixture().toolkit.dispatch(1, "get_current_time", emptyMap()))
val ts = Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")
assertTrue(ts.find(msg) != null, "не ISO 8601 в: $msg")
}
// ---------- таймаут и сессии ----------
@Test
fun `истёкший тулсет исчезает из промпта, скилл возвращается`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
// инжект часов: проскакиваем таймаут без реального ожидания 10 минут
now += ToolsetRegistry.DEFAULT_TIMEOUT_MS + 1_000L
val p = f.toolkit.systemPrompt(1)
assertFalse(p.contains("Активные тулсеты"))
assertFalse(p.contains("media_search|query"))
assertTrue(p.contains("media — Поиск фильмов")) // скилл снова в индексе
}
@Test
fun `активность по сессиям, разные сессии независимы`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
// в сессии 2 вызов тула неактивного набора — подмена активирует только её
val msg = run(f.toolkit.dispatch(2, "media_search", mapOf("query" to "X")))
assertTrue(msg.contains("Результат media_search"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
assertTrue(f.toolkit.systemPrompt(2).contains("Активные тулсеты: media"))
}
@Test
fun `disable в сессии 2 не трогает активный в сессии 1`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(2, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(2, "disable_toolset", mapOf("name" to "media"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
}
// ---------- парсинг аргументов ----------
@Test
fun `парсинг пар и голых значений, прощающий к лишнему`() {
val tk = fixture().toolkit
assertEquals(mapOf("name" to "media"), tk.parseArgs("media", listOf("name")))
assertEquals(emptyMap<String, String>(), tk.parseArgs(null, listOf("name")))
assertEquals(emptyMap<String, String>(), tk.parseArgs(" ", listOf("name")))
assertEquals(mapOf("query" to "Расскл"), tk.parseArgs("query=Расскл", listOf("query")))
assertEquals(
mapOf("id" to "12", "seek_ms" to "300"),
tk.parseArgs("id=12,seek_ms=300", listOf("id", "seek_ms?")),
)
// «голое» значение — первому обязательному аргументу
assertEquals(mapOf("id" to "abc"), tk.parseArgs("abc", listOf("id", "seek_ms?")))
// пар через «&» и лишние ключи — не ошибка
val m = tk.parseArgs("query=x&extra=5", listOf("query"))
assertEquals("x", m["query"])
assertTrue(m.containsKey("extra"))
}
@Test
fun `схема аргументов, schemaFor, у тулов`() {
val tk = fixture().toolkit
assertEquals(emptyList(), tk.schemaFor("get_current_time"))
assertEquals(listOf("text"), tk.schemaFor("show_text"))
assertEquals(listOf("name"), tk.schemaFor("enable_toolset"))
assertEquals(listOf("name"), tk.schemaFor("read_skill"))
assertEquals(listOf("id", "seek_ms?"), tk.schemaFor("media_play"))
assertEquals(emptyList(), tk.schemaFor("unknown_tool"))
}
}
@@ -0,0 +1,38 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Тесты тулов ассистента — чистая логика без БД и Android.
*/
class AgentToolsTest {
@Test
fun `тул времени возвращает ISO 8601 с датой и временем`() {
val result = TimeTool.invoke("{}")
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
assertTrue(
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
"не ISO 8601: $result",
)
assertTrue(result.startsWith("20"), "год не 20xx: $result")
}
@Test
fun `реестр содержит тулу времени с описанием`() {
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
val desc = AgentTools.describeAll()
assertTrue(desc.contains("get_current_time"))
assertTrue(desc.contains("ISO 8601"))
}
@Test
fun `тула времени не зависит от аргументов`() {
// Пустой JSON и мусор — один результат (тула без параметров)
val a = TimeTool.invoke("{}")
val b = TimeTool.invoke("{\"x\":1}")
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
}
}
@@ -0,0 +1,167 @@
package pw.binom.viewmate.phone.agent
import com.google.ai.edge.litertlm.Contents
import com.google.ai.edge.litertlm.Message
import java.io.File
import java.io.IOException
import kotlin.io.path.createTempFile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
/** Тесты LLM-интерфейса без модели/сети: парсинг выбора, маппинг промпта, SHA-256, фабрика. */
class LlmLocalTest {
// ---------- LlmModelChoice.parse ----------
@Test
fun choiceParseDefaultsToRemote() {
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(null))
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(""))
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("unknown"))
}
@Test
fun choiceParseByNameInsensitive() {
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("LOCAL"))
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("local"))
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("remote"))
}
// ---------- buildLocalLlmPrompt ----------
private fun chatHistory() = listOf(
ChatMessage.of(ChatRole.SYSTEM, "Ты Порфирий — голосовой ассистент."),
ChatMessage.of(ChatRole.USER, "включи свет"),
ChatMessage.of(ChatRole.ASSISTANT, "Свет включён."),
ChatMessage.of(ChatRole.TOOL, "выполнено", toolCallId = "c42"),
ChatMessage.of(ChatRole.USER, "погоди, выключи"),
)
@Test
fun promptMapsRolesToLiteRtLm() {
val p = buildLocalLlmPrompt(chatHistory())
// системный промпт = SYSTEM-сообщения (системный параметр не задан)
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
// входящее — последнее непустое USER
assertEquals("погоди, выключи", p.incoming.text())
// история: user, assistant, tool-результат в «Результат тула…» формате
assertEquals(3, p.initialMessages.size)
assertEquals("включи свет", p.initialMessages[0].text())
assertEquals("Свет включён.", p.initialMessages[1].text())
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text())
}
@Test
fun promptFoldsSystemParamAndSystemMessages() {
val history = listOf(
ChatMessage.of(ChatRole.SYSTEM, "контекст A"),
ChatMessage.of(ChatRole.SYSTEM, "контекст B"),
ChatMessage.of(ChatRole.USER, "привет"),
)
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
assertEquals("привет", p.incoming.text())
assertTrue(p.initialMessages.isEmpty())
}
@Test
fun promptToolLastIsIncoming() {
// последняя непустая — TOOL (результат): входящим идёт именно он
val history = listOf(
ChatMessage.of(ChatRole.USER, "что в каталоге?"),
ChatMessage.of(ChatRole.ASSISTANT, "", toolCalls = listOf(ToolCall("c7", "media_search", "{}"))),
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
)
val p = buildLocalLlmPrompt(history)
assertEquals("Результат тула c7: найдено 5", p.incoming.text())
// assistant с пустым текстом в историю не попадает
assertEquals(1, p.initialMessages.size)
assertEquals("что в каталоге?", p.initialMessages[0].text())
}
@Test
fun promptLastIsModelMessageFallsBackToContinue() {
val history = listOf(
ChatMessage.of(ChatRole.USER, "привет"),
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
)
val p = buildLocalLlmPrompt(history)
assertEquals("Продолжай.", p.incoming.text())
assertEquals(2, p.initialMessages.size)
assertTrue(p.systemInstruction == null)
}
@Test
fun promptSkipsEmptyContents() {
val history = listOf(
ChatMessage.of(ChatRole.USER, ""),
ChatMessage.of(ChatRole.ASSISTANT, "привет"),
ChatMessage.of(ChatRole.USER, "как дела"),
)
val p = buildLocalLlmPrompt(history)
// пустое user-сообщение отброшено; входящее — «как дела»
assertEquals(1, p.initialMessages.size)
assertEquals("как дела", p.incoming.text())
}
@Test
fun messageTextExtractsTextParts() {
val m = Message.user(Contents.of("привет"))
assertEquals("привет", m.text())
}
// ---------- SHA-256 ----------
@Test
fun sha256OfFileMatchesKnownContent() {
val file = createTempFile("sha-test").toFile()
try {
file.writeBytes("hello world".toByteArray())
val expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
assertEquals(expected, sha256HexOfFile(file))
assertTrue(sha256OfFileMatches(file, expected))
assertTrue(sha256OfFileMatches(file, expected.uppercase()))
assertFalse(sha256OfFileMatches(file, "deadbeef"))
} finally {
file.delete()
}
}
@Test
fun sha256OfFileMatchesFalseOnMissingFile() {
assertFalse(sha256OfFileMatches(File("/root/WORK/view-mate/definitely-not-here-llm"), ""))
}
// ---------- LocalLlmClient ----------
@Test
fun localClientWithoutModelFailsReadably() {
val missing = File(createTempFile("model").toFile().parentFile, "missing.litertlm")
val client = LocalLlmClient(modelFile = missing, cacheDir = File(missing.parentFile, "cache"))
assertFalse(client.modelDownloaded())
runBlocking {
val e = assertFailsWith<IllegalStateException> {
client.chat(listOf(ChatMessage.of(ChatRole.USER, "привет")))
}
assertTrue(e.message!!.contains("не скачана"), e.message)
}
client.close()
}
// ---------- GemmaModelDownloader ----------
@Test
fun downloaderAllSourcesFailThrows() {
val target = File(createTempFile("model-dl").toFile().parentFile, "model.litertlm")
val dl = GemmaModelDownloader(target, urls = listOf("http://127.0.0.1:1/model.litertlm"))
val e = assertFailsWith<IOException> {
runBlocking { dl.download { } }
}
assertTrue(e.message!!.startsWith("Модель не скачалась (все источники)"), e.message)
assertFalse(target.exists())
}
}
@@ -0,0 +1,267 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.JellyfinItem
/**
* Исполнитель тулсета media: валидация аргументов + форматирование текста
* (search/info/play). Источник и игрок иньекшены фейками — JVM, без Android.
*/
class MediaToolsTest {
private class FakeSource : MediaSource {
val items = mutableListOf(
MediaItem("i1", "Lara Croft", "Movie", 2018, "Tomb Raider reboot", 8100L, true),
MediaItem("i2", "The Witcher", "Series", 2019, null, 3300L, false),
MediaItem("i3", "Stranger Things", "Series", 2016, " ", 3540L, true),
MediaItem("i4", "Blade Runner", "Movie", 1982, "репликанты", 8340L, true),
MediaItem("i5", "Dune", "Series", 2021, null, 4200L, false),
)
var searchFails = false
var itemFails = false
/** Фильтр поиска (по умолчанию все — тестируем лимит). */
var match: (MediaItem) -> Boolean = { true }
override suspend fun search(query: String, limit: Int): List<MediaItem> {
if (searchFails) throw MediaSourceException("нет пользователя Jellyfin (userId пуст)")
return items.filter(match).take(limit)
}
override suspend fun item(itemId: String): MediaItem? {
if (itemFails) throw MediaSourceException("сеть недоступна")
return items.firstOrNull { it.id == itemId }
}
}
private class FakePlayer(
private val responses: Map<String, String> = emptyMap(),
private var failure: Throwable? = null,
) : MediaPlayer {
val calls = mutableListOf<Pair<String, Long>>()
override suspend fun play(itemId: String, seekMs: Long): String {
calls.add(itemId to seekMs)
failure?.let { throw it }
return responses[itemId] ?: "включил: $itemId"
}
}
private fun executor(source: FakeSource = FakeSource(), player: FakePlayer = FakePlayer()) =
MediaToolExecutor(source, player)
// ---------- определение тулсета ----------
@Test
fun `сигнатуры тулсета и префиксная маршрутизация`() {
assertEquals(listOf("search", "info", "play"), MEDIA_TOOLSET.tools.map { it.name })
assertEquals("media_search", MEDIA_TOOLSET.toolFull(MEDIA_TOOLSET.tools.first()))
assertEquals(listOf("id"), MEDIA_TOOLSET.signatureOf("media_play")?.requiredArgs())
assertEquals(listOf("query", "limit"), MEDIA_TOOLSET.signatureOf("media_search")?.argNames())
}
// ---------- media_search ----------
@Test
fun `search строки id название год тип`() {
val source = FakeSource()
source.match = { it.id == "i1" }
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
assertEquals("i1: Lara Croft (2018) — Movie", r)
}
@Test
fun `поиск без года не показывает скобок`() {
val source = FakeSource()
source.items[0] = source.items[0].copy(year = null)
source.match = { it.id == "i1" }
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
assertEquals("i1: Lara Croft — Movie", r)
}
@Test
fun `limit по умолчанию 5, явного, кривой и ноль`() {
val r = executor()
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x")).lines().size)
assertEquals(2, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "2")).lines().size)
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "ab")).lines().size)
// ноль и отрицательное — сжимаются в 1
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "0")).lines().size)
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "-4")).lines().size)
}
@Test
fun `поиск без query — прощающая схема`() {
for (args in listOf(emptyMap<String, String>(), mapOf("query" to ""))) {
val r = executor().execute("media", "media_search", args)
assertTrue(r.contains("нет аргумента \"query\""), r)
assertTrue(r.contains("media_search|<query>"), r)
}
}
@Test
fun `поиск пустая — ничего не найдено`() {
val source = FakeSource()
source.match = { false }
val r = executor(source).execute("media", "media_search", mapOf("query" to "такого нет"))
assertTrue(r.contains("Ничего не найдено"), r)
}
@Test
fun `поиск источник недоступен — сообщение с причиной`() {
val source = FakeSource()
source.searchFails = true
val r = executor(source).execute("media", "media_search", mapOf("query" to "x"))
assertTrue(r.contains("Поиск недоступен"), r)
assertTrue(r.contains("userId пуст"), r)
}
// ---------- media_info ----------
@Test
fun `info полный набор полей`() {
val r = executor().execute("media", "media_info", mapOf("id" to "i1"))
assertTrue(r.contains("«Lara Croft» — Movie (2018)"), r)
assertTrue(r.contains("Описание: Tomb Raider reboot"), r)
assertTrue(r.contains("Длительность: 135 мин. (8100 сек.)"), r)
assertTrue(r.contains("Постер: есть"), r)
}
@Test
fun `info без описания и постера — строки не выдумываются`() {
val r = executor().execute("media", "media_info", mapOf("id" to "i2"))
assertFalse(r.contains("Описание"), r)
assertFalse(r.contains("8100"), r)
assertTrue(r.contains("Постер: нет"), r)
assertTrue(r.contains("55 мин. (3300 сек.)"), r)
}
@Test
fun `info описание длиннее 300 символов — обрезка с многоточием`() {
val source = FakeSource()
source.items[0] = source.items[0].copy(overview = "А".repeat(400))
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
assertTrue(r.contains("Описание: ${"А".repeat(300)}…"), r)
assertFalse(r.contains("А".repeat(301)), r)
}
@Test
fun `info не найден и без аргумента`() {
val r = executor().execute("media", "media_info", mapOf("id" to "iX"))
assertTrue(r.contains("\"iX\" не найден"), r)
val noArg = executor().execute("media", "media_info", emptyMap())
assertTrue(noArg.contains("нет аргумента \"id\""), noArg)
}
@Test
fun `info источник недоступен — сообщение с причиной`() {
val source = FakeSource()
source.itemFails = true
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
assertTrue(r.contains("Детали недоступны"), r)
assertTrue(r.contains("сеть недоступна"), r)
}
// ---------- media_play ----------
@Test
fun `play передаёт id и seek_ms в игрок`() {
val player = FakePlayer()
val executor = executor(player = player)
val r = executor.execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "3600000"))
assertEquals("включил: i1", r)
assertEquals(listOf("i1" to 3_600_000L), player.calls)
// без seek — ноль
assertEquals("включил: i2", executor.execute("media", "media_play", mapOf("id" to "i2")))
assertEquals(2, player.calls.size)
assertEquals(0L, player.calls.last().second)
}
@Test
fun `play без id — схема аргументов`() {
val r = executor().execute("media", "media_play", emptyMap())
assertTrue(r.contains("нет аргумента \"id\""), r)
assertTrue(r.contains("media_play|<id>"), r)
}
@Test
fun `play кривой seek_ms — числовое требование`() {
for (bad in listOf("час", "12.5")) {
val r = executor().execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to bad))
assertTrue(r.contains("\"seek_ms\" должен быть числом"), r)
}
}
@Test
fun `play отрицательный seek — в ноль`() {
val player = FakePlayer()
executor(player = player).execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "-100"))
assertEquals(0L, player.calls.single().second)
}
@Test
fun `play игрок выбросил — обёртка ошибки`() {
val player = FakePlayer(failure = RuntimeException("видео не найдено"))
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
assertTrue(r.contains("Ошибка воспроизведения"), r)
assertTrue(r.contains("видео не найдено"), r)
}
@Test
fun `play текст проверки игрока проходит как есть`() {
val player = FakePlayer(mapOf("i1" to "видео не скачано на очки. Скачайте его."))
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
assertEquals("видео не скачано на очки. Скачайте его.", r)
}
@Test
fun `неизвестный тул — список доступных`() {
val r = executor().execute("media", "media_bogus", emptyMap())
assertTrue(r.contains("Неизвестный тул"), r)
assertTrue(r.contains("media_search, media_info, media_play"), r)
}
// ---------- маппинг Jellyfin → MediaItem ----------
@Test
fun `маппинг полей, постер, секунды из тиков`() {
val item = JellyfinItem(
Id = "abc",
Name = " Blade ",
Type = "Movie",
ProductionYear = 2259,
Overview = "текст",
RunTimeTicks = 33_000_000_000L, // 3300 сек
ImageTags = mapOf("Primary" to "hash"),
)
assertEquals(
MediaItem("abc", "Blade", "Movie", 2259, "текст", 3300L, true),
jellyfinToMediaItem(item),
)
}
@Test
fun `маппинг пустое имя в id, без постера и секунд`() {
val m = jellyfinToMediaItem(JellyfinItem(Id = "x1", Name = " ", Type = "Episode"))
assertEquals("x1", m.name)
assertFalse(m.hasPoster)
assertEquals(null, m.year)
assertEquals(null, m.durationSeconds)
}
// ---------- JellyfinMediaSource: защита userId ----------
@Test
fun `источник без пользователя — исключение у источника`() {
val source = JellyfinMediaSource(JellyfinClient("http://jellyfin.test", "key")) { " " }
runBlocking {
assertFailsWith<MediaSourceException> { source.search("x", 5) }
assertFailsWith<MediaSourceException> { source.item("i1") }
}
}
}
@@ -0,0 +1,101 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import java.io.File
// Скиллы: реестр (JVM, без Android) — файлы skills/*.md в приложении.
class SkillsTest {
private fun freshDir(): File =
File.createTempFile("vw-skills", "").also { it.delete(); it.mkdirs() }
private fun write(dir: File, fileName: String, content: String) {
File(dir, fileName).writeText(content)
}
@Test
fun `файл с frontmatter парсится в скилл`() {
val dir = freshDir()
write(dir, "media.md", """
---
name: media
---
Вызови enable_toolset|media и дождись реестра тул.
1. media_search|query=<название>
""".trimIndent())
val skills = SkillRepository(dir).loadAll()
assertEquals(1, skills.size)
val s = skills.single()
assertEquals("media", s.name)
assertTrue(s.body.contains("enable_toolset|media"))
// frontmatter (заголовок) не должен попасть в тело
assertFalse(s.body.startsWith("---"))
}
@Test
fun `description в frontmatter становится подсказкой (триггером)`() {
val dir = freshDir()
write(dir, "web.md", """
---
name: web
description: Как сделать веб-поиск.
---
Шаги поиска...
""".trimIndent())
val s = SkillRepository(dir).loadAll().single()
assertEquals("web", s.name)
assertEquals("Как сделать веб-поиск.", s.trigger)
assertTrue(s.body.contains("Шаги поиска"))
}
@Test
fun `файл без frontmatter берёт имя из имени файла`() {
val dir = freshDir()
write(dir, "tips.md", "Советы: запускать воспроизведение сразу после буферизации.")
val s = SkillRepository(dir).loadAll().single()
assertEquals("tips", s.name)
assertEquals("Советы: запускать воспроизведение сразу после буферизации.", s.body)
assertEquals("", s.trigger)
}
@Test
fun `пустой каталог и несуществующий каталог — пустой список`() {
val empty = freshDir()
assertTrue(SkillRepository(empty).loadAll().isEmpty())
assertTrue(SkillRepository(File("no-such-skills-dir")).loadAll().isEmpty())
}
@Test
fun `реестр строки индекса и имена`() {
val reg = SkillRegistry(
listOf(
Skill("media", "Поиск фильмов, детали, запуск/управление.", "тело1"),
Skill("web", "Как сделать веб-поиск.", "тело2"),
),
)
assertEquals(listOf("media", "web"), reg.names())
val lines = reg.index()
assertEquals(2, lines.size)
assertTrue(lines.any { it.startsWith("media — ") })
assertTrue(lines.any { it.startsWith("web — ") })
assertTrue(reg.index(excluded = setOf("media")).all { !it.startsWith("media") })
assertTrue(reg.index(excluded = emptySet()) == lines)
}
@Test
fun `readFull полный текст, неизвестный скилл — null и has`() {
val reg = SkillRegistry(listOf(Skill("media", "триггер", "текст_тела")))
val full = reg.readFull("media")
assertNotNull(full)
assertTrue(full.contains("триггер"))
assertTrue(full.contains("текст_тела"))
assertNull(reg.readFull("other"))
assertTrue(reg.has("media"))
assertFalse(reg.has("other"))
}
}
@@ -0,0 +1,145 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Реестр тулсетов: активность на сессию, таймаут (инжект часов — без ожидания
* реальных 10 минут), идемпотентность enable/disable, префиксная маршрутизация.
*/
class ToolsetRegistryTest {
private var now = 1_000_000L
private fun newRegistry(timeoutMs: Long = ToolsetRegistry.DEFAULT_TIMEOUT_MS): ToolsetRegistry {
now = 1_000_000L
return ToolsetRegistry(timeoutMs = timeoutMs, clock = { now })
}
private fun media(): Toolset = Toolset(
name = "media",
description = "Управление медиа и воспроизведением (search/info/play)",
tools = listOf(
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
ToolSignature("info", "Детали фильма.", args = listOf("id")),
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
),
)
@Test
fun `enable активирует набор, активность на сессию`() {
val r = newRegistry()
r.register(media())
val result = r.enable(1, "media")
assertTrue(result is EnableResult.Activated)
assertEquals(listOf("media"), r.activeNames(1))
assertTrue(r.isActive(1, "media"))
// другая сессия — не активна: активность НЕ персистится и НЕ общее
assertFalse(r.isActive(2, "media"))
assertEquals(emptyList(), r.activeNames(2))
}
@Test
fun `повторный enable — продление, не ошибка`() {
val r = newRegistry(timeoutMs = 600_000)
r.register(media())
r.enable(1, "media")
now += 9 * 60_000
val result = r.enable(1, "media") as EnableResult.Activated
assertTrue(result.alreadyActive)
now += 9 * 60_000 // 18 минут суммарно, но от последнего вызова — 9
assertTrue(r.isActive(1, "media"))
now += 600_001L
assertFalse(r.isActive(1, "media"))
}
@Test
fun `истёкший таймаут чистится лениво`() {
val r = newRegistry(timeoutMs = 600_000)
r.register(media())
r.enable(1, "media")
now += 600_001L
assertEquals(emptyList(), r.activeNames(1))
assertFalse(r.isActive(1, "media"))
}
@Test
fun `disable идемпотентен, не ошибка`() {
val r = newRegistry()
r.register(media())
val first = r.disable(1, "media") as DisableResult.NotActive
assertTrue(first.message.contains("Нет активных тулсетов"))
r.enable(1, "media")
assertTrue(r.disable(1, "media") is DisableResult.Disabled)
val again = r.disable(1, "media") as DisableResult.NotActive
assertTrue(again.message.contains("Нет активных тулсетов"))
}
@Test
fun `disable другого набора, когда активен свой — сообщение без «нет активных»`() {
val r = newRegistry()
r.register(media())
r.register(Toolset("web", "Работа с вебом.", emptyList()))
r.enable(1, "web")
val res = r.disable(1, "media") as DisableResult.NotActive
assertTrue(res.message.contains("\"media\" не активен"))
// активный «web» затронут не был
assertTrue(r.isActive(1, "web"))
}
@Test
fun `неизвестный набор — сообщение с доступными в ответе`() {
val r = newRegistry()
r.register(media())
val unknown = r.enable(1, "other") as EnableResult.Unknown
assertEquals("other", unknown.name)
assertTrue(unknown.message.contains("\"other\""))
assertTrue(unknown.message.contains("media"))
val emptyReg = newRegistry()
val empty = emptyReg.enable(1, "other") as EnableResult.Unknown
assertTrue(empty.message.contains("Доступных тулсетов нет"))
}
@Test
fun `маршрутизация по префкису и сигнатура по полному имени`() {
val r = newRegistry()
r.register(media())
assertEquals("media", r.findByPrefix("media_search")?.name)
assertEquals("media", r.findByPrefix("media_play")?.name)
// без подчёркивания это имя набора, не тул
assertEquals(null, r.findByPrefix("media"))
assertEquals(null, r.findByPrefix("web_search"))
assertEquals("search", r.signatureOf("media_search")?.name)
assertEquals(listOf("id", "seek_ms?"), r.signatureOf("media_play")?.args)
assertEquals(null, r.signatureOf("media_bogus"))
}
@Test
fun `touch продлевает таймаут на каждом вызове тулы набора`() {
val r = newRegistry(timeoutMs = 600_000)
r.register(media())
r.enable(1, "media")
now += 9 * 60_000
r.touch(1, "media")
now += 9 * 60_000 // от последнего touch — снова 9 минут
assertTrue(r.isActive(1, "media"))
now += 600_001L
assertFalse(r.isActive(1, "media"))
}
@Test
fun `каталог и активные — независимы, строки каталога по одной на набор`() {
val r = newRegistry()
r.register(media())
r.register(Toolset("web", "Работа с вебом.", emptyList()))
val catalog = r.catalogLines()
assertEquals(2, catalog.size)
assertTrue(catalog.any { it.startsWith("media — ") })
assertTrue(catalog.any { it.startsWith("web — ") })
// каталог есть, активных нет
assertEquals(emptyList(), r.activeNames(7))
}
}
@@ -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,124 @@
package pw.binom.viewmate.phone.stt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertTrue
/**
* Полный текст по клику (TZ-STT): [SttStreamer] накапливает весь PCM сессии
* (без VAD) и [SttStreamer.finish] прогоняет буфер (или хвост 120 с) одним
* вызовом [PhraseRecognizer] с обрезанием краевой тишины, перезаписывая text.
*/
class FullBufferSttTest {
/**
* Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
* по одному на вызов (последний — на все лишние). [calls] читается
* после [SttStreamer.finish] — executor к этому моменту уже отработал
* все задачи (finish дожидается их через Future).
*/
private class RecordingRecognizer(vararg val responses: String) : PhraseRecognizer {
val calls = mutableListOf<FloatArray>()
private var count = 0
override fun recognize(samples: FloatArray): String {
calls.add(samples)
val answer = responses[minOf(count, responses.size - 1)]
count += 1
return answer
}
}
/** s16le-чанк: [count] сэмплов с глобальных индексов [offset] (зубчатая волна, период 32768). */
private fun pcmChunk(offset: Int, count: Int): ByteArray {
val out = ByteArray(count * 2)
var i = 0
while (i < count) {
val u = (offset + i) % 32768
out[2 * i] = (u and 0xff).toByte()
out[2 * i + 1] = ((u ushr 8) and 0xff).toByte()
i += 1
}
return out
}
/** Точная обратная сторона [s16leToFloat] для нашей пачки (по глобальному индексу). */
private fun expectedFloat(globalIndex: Int): Float =
(globalIndex % 32768) / 32768f
private fun newStreamer(recognizer: RecordingRecognizer) = SttStreamer(
stt = recognizer,
onSilence30s = { },
onAutoFinished = { },
)
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
private fun acceptShortSession(streamer: SttStreamer) {
var off = 0
repeat(3) {
streamer.accept(pcmChunk(off, 1600))
off += 1600
}
}
/** Короткая сессия (0.3 с): finish → ОДИН вызов на весь буфер (4800 сэмплов), full = текст. */
@Test
fun shortBufferIsRecognizedInOneCall() {
val recognizer = RecordingRecognizer("привет")
val streamer = newStreamer(recognizer)
try {
acceptShortSession(streamer)
val full = streamer.finish()
assertEquals(1, recognizer.calls.size, "один вызов на весь буфер, без VAD-сегментации")
assertEquals(4800, recognizer.calls[0].size)
assertContentEquals(FloatArray(4800) { expectedFloat(it) }, recognizer.calls[0])
assertEquals("привет", full)
} finally {
streamer.close()
}
}
/** 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов, текст не пуст. */
@Test
fun longBufferPassesLastTwoMinutes() {
val recognizer = RecordingRecognizer("ок")
val streamer = newStreamer(recognizer)
try {
val total = 130 * 16_000
var off = 0
while (off < total) {
streamer.accept(pcmChunk(off, 1600))
off += 1600
}
val full = streamer.finish()
assertEquals(1, recognizer.calls.size)
val chunk = recognizer.calls[0]
assertEquals(16_000 * 120, chunk.size, "ровно хвост 120 с")
assertContentEquals(
FloatArray(16_000 * 120) { expectedFloat(total - 16_000 * 120 + it) },
chunk,
"хвост, а не начало буфера",
)
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
} finally {
streamer.close()
}
}
/** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
@Test
fun resetThenFinishSkipsRecognizer() {
val recognizer = RecordingRecognizer("привет")
val streamer = newStreamer(recognizer)
try {
acceptShortSession(streamer)
streamer.reset()
val full = streamer.finish()
assertEquals(0, recognizer.calls.size, "после reset буфер пуст — whisper не вызывается")
assertEquals("", full)
} finally {
streamer.close()
}
}
}
@@ -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,90 @@
package pw.binom.viewmate.phone.stt
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
/**
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
* внутренние паузы сохраняются, отступ не выходит за границы.
*
* Окно 320 сэмплов = 20 мс @ 16 кГц; отступ [padWindows]=15 → 4800 сэмплов = 300 мс.
* «Речь» — сэмпл 0.5f (RMS окна 0.5 ≥ порог 0.01), «тишина» — 0.005f (ниже порога).
*/
class TrimEdgesTest {
private val win = 320
private val pad = 15 * 320
/** Массив из [count] сэмплов заданного уровня. */
private fun fill(count: Int, level: Float): FloatArray = FloatArray(count) { level }
private fun concat(vararg parts: FloatArray): FloatArray {
var total = 0
for (p in parts) total += p.size
val out = FloatArray(total)
var i = 0
for (p in parts) {
p.copyInto(out, i)
i += p.size
}
return out
}
/** Краевая тишина (100 окон до и после речи в 3 окна) убирается, речь сохраняется. */
@Test
fun edgeSilenceIsTrimmed() {
val lead = fill(100 * win, 0.005f)
val voice = fill(3 * win, 0.5f)
val tail = fill(100 * win, 0.005f)
val samples = concat(lead, voice, tail)
val res = trimEdges(samples)
val start = 100 * win - pad
val stop = 103 * win + pad
assertContentEquals(samples.copyOfRange(start, stop), res, "краевая тишина обрезана, речь сохранена")
}
/** Внутренняя пауза (197 окон тишины между двумя речевыми блоками) полностью сохраняется. */
@Test
fun internalPauseIsPreserved() {
val lead = fill(100 * win, 0.005f)
val voice1 = fill(3 * win, 0.5f)
val pause = fill(197 * win, 0.005f)
val voice2 = fill(3 * win, 0.5f)
val tail = fill(100 * win, 0.005f)
val samples = concat(lead, voice1, pause, voice2, tail)
val res = trimEdges(samples)
val start = 100 * win - pad
val stop = 303 * win + pad
assertContentEquals(samples.copyOfRange(start, stop), res, "внутренняя пауза не выброшена")
}
/** Пустой вход → пустой выход (без ошибок). */
@Test
fun emptyInputReturnsEmpty() {
assertEquals(0, trimEdges(FloatArray(0)).size)
}
/** Всё-тишина (ниже порога) → пустой массив. */
@Test
fun allSilenceReturnsEmpty() {
val res = trimEdges(FloatArray(100 * win) { 0.005f })
assertEquals(0, res.size, "звучных окон нет — результат пуст")
}
/** Отступ не выходит за границы: речь у краёв короткого буфера → весь массив без исключений. */
@Test
fun paddingClampsToBounds() {
val size = 2000
val samples = FloatArray(size)
for (i in 0 until win) samples[i] = 0.5f
for (i in size - win until size) samples[i] = 0.5f
val res = trimEdges(samples)
assertContentEquals(samples.copyOfRange(0, size), res, "clamp к границам, без выхода за массив")
}
}
@@ -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)
}
}
+60
View File
@@ -0,0 +1,60 @@
# Жесты и режимы очков (design, согласовано 2026-08-22)
> Согласовано с пользователем. Реализация — opencode по TASK (см. ниже).
> Это документ проекта: сначала думаем, потом хуячим, потом тесты, потом ОБЯЗАТЕЛЬНО мануальные тесты на железе.
## Ограничения системы (факты)
- Жестов всего шесть: Click, DoubleClick, TripleClick, SlideForward, SlideBackward, LongClick (+ SlideContinuous с delta).
- **LongClick НЕ работает** — перехвачен системным ассистентом RayNeo. Не рассчитывать, не использовать.
- **SlideContinuous существует** (SDK: `TempleAction.SlideContinuous(delta: Float, ...)` — подтверждено javap) — плавный скролл возможен, delta = дельту движения пальца. Частоту событий и амплитуду дельт проверить на железе.
- Экран очков — широкий 1280×480 (640×480 на глаз). Текста влезает мало → скролл ответа обязателен для длинных ответов.
- Оверлей общения затемняет кино (72% чёрного) → режим общения ЯВНЫЙ: пользователь видит, что он в чате, а не в кино. Это позволяет переназначать свайпы в режиме общения без конфликта.
- Нет клавиатуры — только жесты и голос. Выход из режима — только жестом.
- LLM думает 5-15с — нужен индикатор «думаю...», иначе экран замирает.
## Общий паттерн жестов (единая семантика через все режимы)
| Жест | Смысл везде |
|---|---|
| **TripleClick** | вызов локального ассистента (переход в режим общения) |
| **DoubleClick** | отмена / назад |
| **Click** | основное действие текущего контекста |
## Матрица жестов по состояниям (ЗАКРЫТА)
| Состояние | Click | DoubleClick | TripleClick |
|---|---|---|---|
| **Кино** | плей/пауза | игнор (назад некуда) | вызов ассистента (пауза + «слушаю...») |
| **Прослушивание** | отправить фразу | отмена, **остаёмся в диалоге** | игнор |
| **Ответ виден** | новая фраза (микрофон открывается) | **выход из режима** | игнор (уже в режиме) |
Ключевой принцип: **клик = «главное действие здесь»**. В кино — плей/пауза, в диалоге — сказать новую фразу, при прослушивании — отправить. Один жест, контекст решает. Двойной — всегда «не хочу/назад». Тройной — всегда «хочу говорить с ассистентом».
Конфликтов нет: подрежимы различимы по состоянию (listening = микрофон открыт / assistantText непуст = ответ виден).
## Решения по деталям
1. **Отмена диктовки (DoubleClick в прослушивании)** — просто отмена, остаёмся в диалоге (назад к «ответу виден», ничего не теряется).
2. **Фильм после выхода (DoubleClick при ответе)** — ВСЕГДА на паузе, независимо от того, был ли он на паузе до входа в режим ассистента.
3. **Скролл ответа** — плавный: SlideContinuous, отслеживать движение пальца (delta), а не факт свайпа с шагом N. Свайпы SlideForward/SlideBackward в режиме общения — тоже скролл (запасной вариант, если SlideContinuous окажется нестабильным на железе).
4. **Новая фраза в режиме «ответ виден»** — Click: микрофон открывается, «слушаю...». Старый ответ с экрана — стереть (история живёт в SQLite на телефоне, не теряется).
5. **Индикатор «думаю...»** — после отправки фразы (между прослушиванием и ответом) показывать «думаю...» вместо замирания на тексте фразы.
## Что делать (TASK для opencode)
В `app-glasses` (MainActivity.kt + оверлей):
1. **Переразметка жестов по матрице**:
- Кино: как сейчас (Click=TOGGLE, TripleClick=вызов, свайпы=±30с, DoubleClick=игнор)
- Прослушивание: как сейчас (Click=отправить, DoubleClick=отмена, TripleClick=игнор)
- Ответ виден (listening=false, assistantText непуст): **Click → новая фраза** (открыть микрофон, стереть assistantText со старта), **DoubleClick → выход из режима** (закрыть оверлей, фильм на паузе, assistantText стереть), **TripleClick → игнор**, свайпы/SlideContinuous → скролл
2. **Скролл ответа**: плавный через SlideContinuous (delta), scrollState для текста ответа; запасной вариант — дискретные свайпы. Ограничить скролл границами текста.
3. **Индикатор «думаю...»**: состояние между отправкой фразы и получением ShowText; показывать в оверлее.
4. **Выход**: DoubleClick при «ответ виден» — закрыть оверлей, фильм остаётся на паузе (не трогать play/pause при выходе, только при входе ставим паузу).
Тесты: юнит-тесты на чистую логику разметки жестов (переходы состояний, скролл-дельта → смещение), в духе GestureCommandsTest. Мануальные тесты — ОБЯЗАТЕЛЬНО на железе (142: очки 7687f645 + телефон AXGL024B05001337): полный цикл кино → тройной тап → фраза → «думаю...» → ответ → скролл → выход → фильм на паузе.
## Смежные договорённости (архитектура агента)
Полная архитектура скиллов/тулсетов — в `TASK-agent-tools.md`. Ключевое для очков: ассистент — локальный, на телефоне; очки — тонкий клиент (микрофон → STT → телефон → LLM → ShowText на очки). Режим общения — поверх любого режима (кино/чат), активируется тройным тапом из любого места.
+158
View File
@@ -0,0 +1,158 @@
# OpenAssistant (vayun-mathur/Modern-Apps) — локальные нейронки на Android
Источник: https://github.com/vayun-mathur/Modern-Apps/tree/main/openassistant
Клон: ~/WORK/modern-apps/ (depth 1). Разбор 2026-08-10.
## Текстовые альтернативы E2B в формате .litertlm (проверено 2026-08-10, полка litert-community)
Вопрос: есть ли ТЕКСТОВЫЕ модели заметно умнее E2B (2B) за те же/чуть большие токены, тот же формат.
| Модель | Парам. | Контекст | Размер | Декод на 8 Gen 3 | Оценка |
|---|---|---|---|---|---|
| Gemma 4 E2B (текущая) | 2B | 32K | ~0.8 ГБ | 20-35 т/с (спекуляция вкл) | база сравнения |
| Qwen3-8B (mixed int4) | 8B | 2048 (!) | 4.66 ГБ | ~10-12 т/с (оценка, телеф. бенча нет) | заметно умнее, но контекст урезан вдрызг |
| Phi-4-mini-instruct | 3.8B | 4096 | 3.9 ГБ | 10.39 т/с GPU (S24 Ultra, 8 Gen 3, динамик int8) | умнее, в 2 раза медленнее |
| Ministral-3-3B-Reasoning | 3.3B | 4096 | ~2.2 ГБ | нет телеф. бенча | reasoning, текст, но медленный по природе |
| DeepSeek-R1-Distill-Qwen-1.5B | 1.5B | 4096 | 1.83 ГБ | 26.35 т/с CPU (S24 Ultra) | не умнее, другой класс (reasoning) |
| Gemma2-2B-IT | 2B | — | — | — | доступ ограничен (restricted) |
| Gemma3-4B-IT | 4B | — | — | — | мультимодальная, не подходит по критерию |
ВЫВОД: за "умнее + текст" платишь скоростью и контекстом. Phi-4-mini — единственный с честным замером на 8 Gen 3 (10.39 т/с — вдвое медленнее 20-25). Qwen3-8B — умнее всех, но 2048 контекст и ~10 т/с. E2B остаётся оптимумом для "незаметной" скорости (20+ т/с).
## Две нейронки + токенайзер
### 1. Чат-LLM: Gemma 4 E2B (2B) — файл gemma-4-E2B-it.litertlm
- Формат .litertlm (LiteRT LM, Google; бывш. AI Edge / MediaPipe LLM Inference)
- Библиотека: com.google.ai.edge.litertlm:litertlm-android:0.14.0
- Запуск: Engine(EngineConfig(modelPath, backend=GPU(), visionBackend=GPU(), audioBackend=CPU(), cacheDir)) + engine.initialize()
- Speculative decoding: ExperimentalFlags.enableSpeculativeDecoding = true
- Мультимодальный ввод: Content.Text / Content.ImageFile / Content.AudioFile (голос — WavRecorder, обрабатывается на CPU)
- Источник: huggingface.co/litert-community/gemma-4-E2B-it-litert-lm
- SHA256: 181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c
- На диске: gemma4-2b.litertlm в getExternalFilesDir(null)
### 2. Эмбеддер SigLIP2-base (patch16, 224) — семантический поиск по фото
- Служит фото-приложению через ResultReceiver (IPC): text/image/info-запросы
- ONNX Runtime: com.microsoft.onnxruntime:onnxruntime-android:1.27.0
- Два файла: vision_model_fp16.onnx (картинки), text_model_int8.onnx (текст)
- Вектора 768-мерные (dim читается из модели), L2-нормированные, косинус
- Источник: huggingface.co/onnx-community/siglip2-base-patch16-224-ONNX
- SHA256 vision: a1959f7bd3993a607e48839f6d01e25b876fe76afda301b028b78eef68aabd95
- SHA256 text: 3a0603d3a00c05a80a6ded4743c16aaac7b1e62cdcc7e362e7ce418659b96400
- ВАЖНО: из выходов брать pooler_output (ранг-2), НЕ last_hidden_state (ранг-3), иначе вектора бессмысленны
- Препроцессинг картинки: ресайз прямо в 224x224 (без center-crop), (px-127.5)/127.5, NCHW RGB
- Сессии ONNX однопоточные (setIntraOpNumThreads(1)) — бережёт батарею
### 3. Токенайзер SentencePiece (Gemma tokenizer.model, ~4MB, 256k vocab)
- Файл: siglip2_tokenizer.model (SHA256 61a7b147390c64585d6c3543dd6fc636906c9af3865a5548f27f31aee1d4c8e2)
- Нужен ТОЛЬКО текстовой башне SigLIP2 (litertlm не отдаёт token id наружу)
- Реализация без protobuf-зависимости: ручной парсинг ModelProto (pieces = field 1; piece=1 string, score=2 float, type=3 enum; индекс == token id)
- Кодирование: Viterbi Unigram сегментация + byte fallback (<0xNN>, Gemma шлёт все 256 байтовых писов)
- Препроцессинг текста: lowercase → NFKC → схлопнуть пробелы → dummy-prefix пробел → ▁ (U+2581)
- SEQ_LEN = 64 (падинг padId=0, обрезка)
- Риск: совпадение с transformers Siglip2Processor — первое, что проверять при повторе
## Как это работает (архитектура)
- Foreground-сервис InferenceService, 3 очереди: standard (чат), intent (JSON-извлечение), embedding (SigLIP2 — отдельный consumer, не ждёт LLM)
- Модели качаются ТОЛЬКО с зеркала https://data.vayunmathur.com/models/ (никакого HF-фолбэка, supply-chain митигация), SHA-256 проверка, Content-Length + ETag
- Чат: история из Room-БД → ConversationConfig(systemInstruction, initialMessages, tools, automaticToolCalling=true) → sendMessageAsync стриминг
- Инструменты (AssistantToolSet): add_to_memory / get_memories / remove_memory и др., automaticToolCalling=true
- Intent-режим: system prompt "data extraction engine", стриминг, tryExtractLargestJson + валидация по JSON Schema, досрочный HALT через CancellationException("HALT"), таймаут 45с, очередь-дроп по 45с
- Версионирование: cleanupStaleSiglipModels по SiglipEmbedder.MODEL_VERSION; legacy gemma4.litertlm / gemma4-4b.litertlm удаляются при старте
- Сборка: noCompress += "onnx"; packaging pickFirsts: libLiteRtTopKOpenClSampler.so, libc++_shared.so
## Ключевой вывод (что ценно для нас)
Главный урок не в архитектуре, а в скорости: автор довёл локальную нейронку на телефоне до >=20-25 ток/с — пользователь не видит задержки, ассистент отвечает "на скидку" (вживую, без заметных пауз). Это рецепт быстрого запуска нейронки на устройстве:
- Модель 2B (Gemma 4 E2B) в оптимизированном формате LiteRT LM — компактная, но живая
- GPU-бэкенд для text/vision (OpenCL), CPU только для аудио
- Speculative decoding включён (ExperimentalFlags) — дёшево ускоряет генерацию
- Одна сессия движка, переиспользуется; очереди разнесены, эмбеддинги не блокируют чат
- Токенайзер ручной (без protobuf), эмбеддер на ONNX — но это обвязка, не узкое место
Т.е. заявка на повтор: 2B-модель + LiteRT LM + GPU + speculative decoding = быстрый локальный ассистент.
## Ответы на вопросы (2026-08-10)
### Что за формат .litertlm и можно ли заменить модель
- .litertlm — контейнер LiteRT-LM (Google; бывш. MediaPipe LLM Inference / AI Edge): внутри квантованная модель (смесь 2/4/8 бит, text-only вес ~0.8GB), эмбеддинги (1.12GB, memory-mapped), токенайзер, плюс движок даёт KV-cache менеджмент, промпт-темплейт, function calling
- ЗАМЕНИТЬ МОЖНО, и это просто: готовые .litertlm в litert-community на HF (десятки): Qwen3 0.6B/1.7B/8B, Qwen2.5 0.5B/1.5B, TinyLlama-1.1B, Phi-4-mini, SmolLM-135M, DeepSeek-R1-Distill-Qwen-1.5B (reasoning), Gemma3-1B/4B, FunctionGemma-270M (function calling), Nanbeige-3B, Ministral-3-3B, Mage-VL (VLM), FastVLM-0.5B. Свои модели — через LiteRT Torch Generative API (ai-edge-litert, Python)
- В openassistant замена = поменять URL+SHA256 в ModelUrls и файл. SigLIP2-токенайзер от LLM не зависит
- Ограничения при замене: (а) speculative decoding вшит в сборку модели — у Gemma 4 E2B есть, у других не факт; (б) Content.ImageFile/AudioFile работают только у мультимодальных сборок; (в) tools/automaticToolCalling — у Gemma/Qwen/Phi есть, не у всех
- Для скорости на слабом железе: Qwen3-0.6B / SmolLM-135M / Qwen2.5-0.5B (быстрее, но глупее); компромисс: Qwen3-1.7B, Gemma3-1B; русский текст у Qwen обычно лучше
### Мультимодальность Gemma 4 E2B
- ДА: текст + картинки + аудио в одном сообщении. В коде: Content.Text / Content.ImageFile / Content.AudioFile; text/vision на GPU, audio на CPU
- Из README: картинки/аудио класть ПЕРЕД текстом в промпте; переменное разрешение картинок через visual token budget (токенов на картинку); vision/audio части грузятся по требованию
- В openassistant intent-режим шлёт imagePaths + userText одним Messages.user — живой пример смешанного ввода
### Контекст
- Полная Gemma 4 — до 256K токенов (model card). On-device litertlm-сборка: "supports up to 32k context length" — 32K токенов
- Бенчмарки litert-community: 1024 prefill + 256 decode, контекст 2048; S26 Ultra GPU: decode 52 ток/с (baseline), 66-92 ток/с со speculative decoding; prefill 3808 ток/с; TTFT 0.3с
- Приложение лимита не задаёт (ConversationConfig без maxTokens) — контекстом рулит движок (KV-cache менеджмент); история из Room идёт в initialMessages, упор в 32k движок разруливает сам
- 20-25 ток/с автора — нижняя граница "незаметно", на флагмане официально 46-92
### Скорость на Snapdragon 8 Gen 3 (12 ГБ RAM)
- УСТРОЙСТВО АВТОРА (подтверждено 2026-08-10): Honor Magic V3, Snapdragon 8 Gen 3, 12 ГБ RAM. (Сначала назвал Gen 2 — ложный след, снят с протокола.)
- Официальная карточка litert-community НЕ даёт 8 Gen 3 — бенчмарки только на S26 Ultra (чип 2026, новее): GPU decode 52.1 ток/с baseline, 66.5-91.7 со speculative decoding; prefill 3808 ток/с; TTFT 0.3с; CPU: decode 46.9, prefill 557, TTFT 1.8с. Память: GPU-бэкенд 676 МБ, CPU 1733 МБ
- 8 Gen 3 (2023, Adreno 750, напр. S24 Ultra US): официальных цифр нет; сторонние замеры (MindStudio): E2B 20-35 ток/с, E4B 12-20 ток/с. Прим.: у MindStudio Pixel 9 приписан к 8 Gen 3 ошибочно (там Tensor G4); настоящий 8 Gen 3 — S24 Ultra (US) и другие
- Память 12 ГБ — с большим запасом: модель на GPU ~676-800 МБ RSS (text-only), эмбеддинги 1.12 ГБ memory-mapped (не в RSS), vision/audio по требованию. Ограничение не в RAM, а в скорости GPU/пропускной способности
- Вывод: заявленные автором 20-25 ток/с — реалистичная нижняя/средняя граница вилки для 8 Gen 3; со speculative decoding можно ждать заметно больше (порядка 50+ на флагмане, по аналогии с S26 Ultra)
### Спекулятивное декодирование (MTP) на Snapdragon 8 Gen 3
- Что это: LLM обычно выдаёт 1 токен за проход сети. Спекулятивное декодирование: маленький черновик-предсказатель набрасывает сразу N следующих токенов, большая модель проверяет их ОДНИМ проходом — если угадал, получил N токенов ценой одного прохода. Ускорение до ~3x без потери качества
- У Gemma 4 это НЕ внешняя надстройка, а встроенная архитектура — Multi-Token Prediction (MTP): черновик-головы сидят прямо в чекпоинте модели (ai.google.dev/gemma/docs/mtp). Спекуляция у Gemma 4 работает автоматически при поддержке рантайма; флаг лишь включает рантайм-путь
- В openassistant флаг ПОДТВЕРЖДЁН в коде: InferenceService.kt:486 — ExperimentalFlags.enableSpeculativeDecoding = true перед engine.initialize()
- ВЫВОД: 20-25 ток/с автора НЕ означают, что спекуляция выключена. Наоборот — она включена. У автора Honor Magic V3 = Snapdragon 8 Gen 3 (Adreno 750): вилка со спекуляцией ~20-35 ток/с (MindStudio), без неё ~10-15 (вдвое меньше). Его 20-25 — ровно середина ожидаемого С включённой спекуляцией, ближе к нижней границе (зависит от термальных лимитов складного корпуса)
- Как проверить на живом телефоне: поставить enableSpeculativeDecoding = false, замерить — падение вдвое = она работала
- 8 Gen 3 (2023, Adreno 750): сторонние замеры E2B 20-35 ток/с; на 8 Gen 2 (2022, Adreno 740) было бы ниже на ~15-20%
## Как повторить (чеклист)
1. Скачать 4 файла с HF (см. SHA256 выше), разложить под зеркало /models/
2. Android: litertlm-android 0.14.0 + onnxruntime-android 1.27.0 (+ kotlinx-coroutines 1.11.0 — иначе close$default на SendChannel)
3. Backend: GPU для text/vision, CPU для audio
4. Из SigLIP2 ONNX читать pooler_output, не last_hidden_state
## Бонус: что ещё в монорепо Modern-Apps
findfamily (UWB-локатор), keyboard (хангыль-композитор), maps, games (Stockfish через JitPack ncnn), библиотеки: downloadservice, room, image.
## Raspberry Pi 5: да, работает
Вопрос: потянет ли Gemma 4 E2B Raspberry Pi 5? Ответ: да, и это официально.
Формат и рантайм:
- LiteRT-LM теперь официально кросс-платформенный: Android, iOS, Web, Desktop, IoT (Raspberry Pi). Есть официальный CLI (uvx-установка), который гоняет .litertlm прямо на Pi — без конвертации. GitHub: google-ai-edge/LiteRT-LM.
- Google сам пишет «Try Gemma4-E4B with MTP on Linux, macOS, Windows or Raspberry Pi with the LiteRT-LM CLI».
Реальные замеры на RPi 5 (8 ГБ, 4×Cortex-A76 @ 2.4 ГГц, без GPU-акселератора):
1. Ollama (GGUF): первый токен ~3-4 сек, генерация ~8-12 t/s. Модель ~1.5 ГБ.
2. Стоковый llama.cpp (Q4): ~6.6 t/s.
3. mkturkcan/mote (оптимизированный llama.cpp + MTP-спекуляция + A76-ядра + KleidiAI): 11.2 t/s на обычном тексте, до 16 на шаблонном, до 20.4 в turbo на повторах (логи/CSV). Устанавливается одной командой, ничего не собирается на Pi.
4. Официальный LiteRT-LM CLI: цифры не подтверждены, но движок тот же — ожидаемо в районе 6-12 t/s.
Важно про контекст на Pi:
- Модель заявляет 128K контекста, но KV-кэш на 128K не влезает в 8 ГБ. На RPi 5 (8 ГБ) реально 4K-8K токенов, дальше — своп и обвал скорости. На 16 ГБ — комфортно ~32K.
- Мультимодальность (текст+картинки+видео) на Pi работает, но медленнее телефона.
Вывод: на RPi 5 E2B даёт ~8-12 t/s (сток) или ~11-20 t/s (оптимизированный mote). Это в 2-3 раза медленнее телефона (20-25 t/s), но для офлайн-ассистента, пакетной обработки и домашних экспериментов — достаточно. Воспринимается как «медленно, но читаемо», а не как «зависло».
## Батарея и скорость обработки картинок (Honor Magic V3, 8 Gen 3, 12 ГБ)
### Батарея
- Точных ватт на Android под GPU-нагрузкой НЕТ ни у кого: Battery Manager API Android ненадёжен под нагрузкой, iOS API не отдаёт (arxiv 2603.23640, "LLM Inference at the Edge", бенчмарк на S24 Ultra = тот же 8 Gen 3).
- Ориентиры из той статьи: S24 Ultra idle ~0.8 Вт, max ~12 Вт (система целиком). Qwen 1.5B через MLC-LLM: 9.93 ток/с sustained, префилл 25 сек (OpenCL-оверхед MLC), термальный фейл на 6-й итерации подряд: GPU freq упала до 231 МГц, 78.3°C — жёсткий флор от термогубернатора, а не плавный DVFS как у iPhone.
- Энергия на токен на edge-платформах: ~270-300 мДж/токен (Hailo-10H 1.87 Вт / 6.9 ток/с; RTX 4050 ноут 34 Вт / 131.7 ток/с).
- Honor Magic V3: батарея 5150 мАч ≈ 19.9 Вт·ч.
- Прикидка: Gemma 4 E2B на 8 Gen 3 ~22 ток/с при ~4-5 Вт системы → 0.2 Дж/токен ≈ 56 мкВт·ч/токен. Непрерывная генерация ≈ 4-4.5 часа на всю батарею (20-25%/час). Сценарий ассистента (5-10 мин генерации в час) → 1.5-4%/час — почти незаметно.
- Термальный капкан 8 Gen 3: непрерывная нагрузка без пауз → троттлинг, скорость падает до ~2 раз. Magic V3 тонкий — отвод тепла хуже, троттлинг раньше. Паузы между запросами обязательны (раз в несколько минут — ок).
- LiteRT-LM оптимизирован под Adreno лучше MLC-LLM (это гугловский движок), поэтому 20-25 ток/с у автора — уже с учётом реального троттлинга.
### Картинки (видео-поток с камеры)
- Gemma 4: переменное разрешение через токен-бюджет: 70/140/280/560/1120 токенов на картинку. Google прямо советует для кадров видео НИЗКИЙ бюджет (70) ради скорости. Механика: 9 патчей на токен (3x3 среднее).
- Каждый кадр = вижн-энкодер + префилл токенов бюджета. Порядок: 0.3-1.5 сек на кадр на 8 Gen 3 (оценка, точного бенчмарка нет).
- 30 fps НЕВОЗМОЖНО: 70 токенов/кадр × 30 = 2100 ток/с префилла — префилл на телефоне ~200-500 ток/с. Реалистично: кадр раз в 2-5 сек, бюджет 70-140.
- Контекст 32K: при 140 ток/кадр ≈ 230 кадров в окне, при 70 ≈ 450. Кадр раз в 2 сек → 8-15 минут непрерывного окна → нужен скользящий буфер кадров.
- Для "где я иду/еду": модель получает кадр + вопрос ("где я?"), отвечает за 1-3 сек. Рабочий вариант — периодический анализ, а не поток. Сколько картинок за сообщение принимает on-device сборка — проверять на практике (в доке: reasoning across multiple images).
- Код автора: Content.ImageFile(path) шлёт файлы как есть, бэкенд GPU для текста+зрения (InferenceService.kt:493-494), аудио CPU. Приложение лимит картинок не задаёт.
+6 -1
View File
@@ -1,7 +1,7 @@
[versions] [versions]
kotlin = "2.4.10" kotlin = "2.4.10"
kotlinx-serialization = "1.8.1" kotlinx-serialization = "1.8.1"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.11.0"
ktor = "3.3.0" ktor = "3.3.0"
android = "8.9.2" android = "8.9.2"
compose-plugin = "1.8.0" compose-plugin = "1.8.0"
@@ -10,6 +10,7 @@ androidx-activity-compose = "1.10.1"
exoplayer = "2.17.1" exoplayer = "2.17.1"
media3 = "1.6.1" media3 = "1.6.1"
koog = "1.1.1" koog = "1.1.1"
litertlm-android = "0.14.0"
[libraries] [libraries]
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
@@ -23,6 +24,10 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" } exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
# LiteRT LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
# litertlm-android 0.14.0 нужен на kotlinx-coroutines 1.11.0 (shared версия поднята)
litertlm-android = { module = "com.google.ai.edge.litertlm:litertlm-android", version.ref = "litertlm-android" }
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый) # Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client; # Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
# отдельный артефакт prompt-executor-openai-model существует только до 0.4.1. # отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.
@@ -18,6 +18,8 @@ data class JellyfinItem(
val Id: String, val Id: String,
val Name: String? = null, val Name: String? = null,
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
/** Год релиза (Movie/Series); в ответах может отсутствовать. */
val ProductionYear: Int? = null,
val Overview: String? = null, val Overview: String? = null,
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
val SeriesName: String? = null, // для эпизодов val SeriesName: String? = null, // для эпизодов
@@ -0,0 +1,20 @@
package pw.binom.viewmate.core.net
/**
* Константы Bluetooth-транспорта (SPP/RFCOMM) — TASK-transport.md п.3/п.4.
* Общие для стороны очков (клиент) и телефона (сервер): UUID-сервиса SPP,
* имя сервиса, разделитель строк-сообщений, период реконнекта.
*/
object BtTransportConstants {
/** UUID SPP-сервиса (Serial Port Profile, стандартный Bluetooth SIG). */
const val SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
/** Имя SPP-сервиса (RFCOMM-заголовок + человекочитаемый лог). */
const val SERVICE_NAME = "viewmate-spp"
/** Разделитель сообщений (JSON + перевод строки) в SPP. */
const val NEWLINE: Char = '\n'
/** Задержка (мс) между попытками реконнекta BT (оба конца — одинаковый цикл). */
const val RETRY_DELAY_MS: Long = 3_000L
}
@@ -0,0 +1,63 @@
package pw.binom.viewmate.core.net
/**
* Транспорт связи очки↔телефон, сторона очков («клиент») — TASK-transport.md п.1.
* Сообщения ходят **JSON-строками**: декод/кодинг конвертов [HostToGlasses]/
* [GlassesToHost] делает вызывающий (HostConnection на очках, GlassesHub на
* телефоне) — транспорт остаётся независимым от протокола.
*
* Реализации: [WifiGlassesTransport] (WebSocket, дефолт) и BtGlassesTransport
* (Bluetooth SPP, флагом).
*/
interface GlassesTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Подключиться к телефону: бесконечный цикл реконнекта (ретрай через 3с при
* обрыве) живёт внутри реализации, в корутине вызывающего.
*
* @param onMessage входящее JSON-сообщение (конверт HostToGlasses, строкой).
*/
suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
)
/** Отправить JSON-сообщение (конверт GlassesToHost) на телефон. Без активного соединения — no-op. */
suspend fun send(json: String)
/** Закрыть транспорт: остановить цикл реконнекта и активное соединение. */
fun close()
}
/**
* Транспорт приёма соединений, сторона телефона («сервер») — TASK-transport.md п.1.
* [accept] выполняется бесконечно: принимает подключения очков и отдаёт события
* по строковым [connId] — не по объектам сокета/сессии (требование ТЗ:
* «сессии — connId-строки»). Сообщения — JSON-строки, декод — не в его
* заботе (запускающий: GlassesHub).
*/
interface GlassesServerTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Бесконечно принимать подключения очков (в корутине вызывающего).
* @param onMessage входящее JSON-сообщение (конверт GlassesToHost) из соединения [connId].
* @param onConnected установлено новое соединение [connId].
* @param onDisconnected соединение [connId] оборвано.
*/
suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
)
/** Отправить JSON-сообщение конкретному соединению [connId]. Соединения нет — no-op. */
suspend fun send(connId: String, json: String)
/** Закрыть транспорт: перестать принимать и закрыть активные соединения. */
fun close()
}
@@ -10,23 +10,21 @@ import io.ktor.websocket.send
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.log import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.defaultWsClient import pw.binom.viewmate.core.media.defaultWsClient
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.protocolJson
/** /**
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost], * WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с * (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
* реконнектом через 3с при обрыве. * бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
*/ */
class GlassesWsClient( class GlassesWsClient(
private val url: String, // ws://<host>:8080/ws/glasses private val url: String, // ws://<host>:8080/ws/glasses
private val http: HttpClient = defaultWsClient(), private val http: HttpClient = defaultWsClient(),
) { ) : GlassesTransport {
override val name = "wifi"
@Volatile @Volatile
private var closed = false private var closed = false
@@ -36,13 +34,13 @@ class GlassesWsClient(
/** /**
* Подключиться и слушать. Возвращается при вызове [close]. * Подключиться и слушать. Возвращается при вызове [close].
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] — * [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
* события (пере)подключения (для Hello и смены статуса в UI). * события (пере)подключения (для Hello и смены статуса в UI).
*/ */
suspend fun connect( override suspend fun connect(
onHostMessage: suspend (HostToGlasses) -> Unit, onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit = {}, onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit = {}, onDisconnected: suspend () -> Unit,
) { ) {
while (!closed) { while (!closed) {
try { try {
@@ -52,9 +50,7 @@ class GlassesWsClient(
try { try {
for (frame in ws.incoming) { for (frame in ws.incoming) {
if (frame is Frame.Text) { if (frame is Frame.Text) {
val text = frame.readText() onMessage(frame.readText())
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
onHostMessage(msg)
} }
} }
} finally { } finally {
@@ -72,14 +68,13 @@ class GlassesWsClient(
} }
} }
/** Отправить сообщение хосту. Без активного соединения — no-op. */ /** Отправить JSON-строку хосту. Без активного соединения — no-op. */
suspend fun send(msg: GlassesToHost) { override suspend fun send(json: String) {
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg) session?.send(json)
session?.send(text)
} }
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */ /** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
fun close() { override fun close() {
closed = true closed = true
session?.cancel() session?.cancel()
} }
@@ -0,0 +1,36 @@
package pw.binom.viewmate.core.net
import io.ktor.client.HttpClient
import pw.binom.viewmate.core.media.defaultWsClient
/**
* WiFi-транспорт, сторона очков («клиент») — TASK-transport.md п.2:
* [GlassesTransport] поверх [GlassesWsClient] — обёртка, логика цикла
* подключения/реконнекта не переписывается (переносится в интерфейс).
* Адрес (mDNS/scan/fallback — HostConnection) передаётся в URL.
*/
class WifiGlassesTransport(
url: String,
http: HttpClient = defaultWsClient(),
) : GlassesTransport {
override val name = "wifi"
private val client = GlassesWsClient(url, http)
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
client.connect(onMessage, onConnected, onDisconnected)
}
override suspend fun send(json: String) {
client.send(json)
}
override fun close() {
client.close()
}
}
@@ -17,6 +17,7 @@ import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.SetVertical
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Shutdown import pw.binom.viewmate.core.protocol.Shutdown
@@ -91,8 +92,15 @@ class PhoneActions(
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках. * Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем). * Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон. * Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
* [startPositionMs] — стартовая позиция (seek для запуска с середины).
*/ */
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = "", phoneAudioReady: Boolean = true): String { suspend fun watch(
itemId: String,
audioIndex: Int = 0,
localVideoPath: String = "",
phoneAudioReady: Boolean = true,
startPositionMs: Long = 0,
): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден" val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId) val status = mirror.mirrorByItem(itemId)
val files = status?.files val files = status?.files
@@ -107,7 +115,7 @@ class PhoneActions(
} }
val title = item.Name ?: itemId val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex)) sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex, startPositionMs = startPositionMs))
// очки не помнят scale между запусками — при старте просмотра шлём текущий // очки не помнят scale между запусками — при старте просмотра шлём текущий
if (state.scale != 1f) sender.send(SetScale(state.scale)) if (state.scale != 1f) sender.send(SetScale(state.scale))
state.mode = GlassesMode.MOVIE state.mode = GlassesMode.MOVIE
@@ -268,6 +276,13 @@ class PhoneActions(
return "масштаб → $clamped" return "масштаб → $clamped"
} }
/** Вертикаль экрана очков: SetVertical (0..1; 1 — верх, 0 — низ). */
suspend fun applyVertical(vertical: Float): String {
val clamped = vertical.coerceIn(0f, 1f)
sender.send(SetVertical(clamped))
return "высота → $clamped"
}
/** Закрыть приложение на очках (телефон остаётся открытым). */ /** Закрыть приложение на очках (телефон остаётся открытым). */
suspend fun shutdownGlasses(): String { suspend fun shutdownGlasses(): String {
sender.send(Shutdown()) sender.send(Shutdown())
@@ -39,6 +39,16 @@ class PhoneState {
log("state", "масштаб → $value") log("state", "масштаб → $value")
} }
private val _vertical = MutableStateFlow(1f)
val verticalFlow: StateFlow<Float> = _vertical.asStateFlow()
/** Вертикаль экрана очков (0..1): 1.0 — у верха, 0.0 — нижняя кромка у низа. */
var vertical: Float
get() = _vertical.value
set(value) {
_vertical.value = value.coerceIn(0f, 1f)
log("state", "высота → $value")
}
private val _currentItem = MutableStateFlow<JellyfinItem?>(null) private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow() val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
var currentItem: JellyfinItem? var currentItem: JellyfinItem?
@@ -81,6 +81,11 @@ data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlas
@SerialName("set_scale") @SerialName("set_scale")
data class SetScale(val scale: Float) : HostToGlasses data class SetScale(val scale: Float) : HostToGlasses
/** Вертикальное положение экрана очков (0..1); 1.0 — у верха, 0.0 — нижняя кромка у низа. */
@Serializable
@SerialName("set_vertical")
data class SetVertical(val vertical: Float) : HostToGlasses
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */ /** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
@Serializable @Serializable
@SerialName("stt_phrase") @SerialName("stt_phrase")
@@ -91,10 +96,32 @@ 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="cancel" — явная отмена диктовки двойным тапом: очки закрывают микрофон,
* listening=false, thinking=false, остаются в режиме (показывают последний ответ);
* reason="timeout" — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима.
*/
@Serializable @Serializable
@SerialName("stt_cancel") @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 @Serializable
@@ -221,5 +221,48 @@ 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
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)
} }
} }
@@ -34,11 +34,20 @@ class GlassesWsClientTest {
companion object { companion object {
private const val PATH = "/ws/glasses" private const val PATH = "/ws/glasses"
private val json = protocolJson
private fun hostToGlasses(text: String): HostToGlasses =
json.decodeFromString(HostToGlasses.serializer(), text)
private fun glassesToHost(text: String): GlassesToHost =
json.decodeFromString(GlassesToHost.serializer(), text)
} }
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */ /** Локальный WS-сервер: Hello → Welcome, всё входящее (JSON-строками) собирает в очередь. */
private class TestServer(private val port: Int = 0) { private class TestServer(private val port: Int = 0) {
val received = ConcurrentLinkedQueue<GlassesToHost>() /** Входящие JSON-строки (транспорт — независим от протокола). */
val receivedRaw = ConcurrentLinkedQueue<String>()
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>() private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") { private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
try { try {
for (frame in incoming) { for (frame in incoming) {
if (frame is Frame.Text) { if (frame is Frame.Text) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText()) val text = frame.readText()
received.add(msg) receivedRaw.add(text)
val msg = glassesToHost(text)
if (msg is Hello) { if (msg is Hello) {
send(protocolJson.encodeToString( send(json.encodeToString(
HostToGlasses.serializer(), HostToGlasses.serializer(),
Welcome(mode = GlassesMode.MOVIE), Welcome(mode = GlassesMode.MOVIE),
)) ))
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
get() = sessions.size get() = sessions.size
suspend fun broadcast(msg: HostToGlasses) { suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg) val text = json.encodeToString(HostToGlasses.serializer(), msg)
for (s in sessions) { for (s in sessions) {
try { try {
s.send(text) s.send(text)
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
try { try {
runBlocking { runBlocking {
val client = GlassesWsClient(server.url()) val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>() val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>() val connected = CompletableDeferred<Unit>()
val job = launch { val job = launch {
client.connect( client.connect(
onHostMessage = { messages.add(it) }, onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) }, onConnected = { connected.complete(Unit) },
onDisconnected = {},
) )
} }
withTimeout(10_000) { connected.await() } withTimeout(10_000) { connected.await() }
client.send(Hello(appVersion = "0.1.0")) client.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
awaitTrue { messages.any { it is Welcome } } awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
val welcome = messages.filterIsInstance<Welcome>().first() val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
assertEquals(GlassesMode.MOVIE, welcome.mode) assertEquals(GlassesMode.MOVIE, welcome.mode)
client.close() client.close()
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
val connected = CompletableDeferred<Unit>() val connected = CompletableDeferred<Unit>()
val job = launch { val job = launch {
client.connect( client.connect(
onHostMessage = {}, onMessage = {},
onConnected = { connected.complete(Unit) }, onConnected = { connected.complete(Unit) },
onDisconnected = {},
) )
} }
withTimeout(10_000) { connected.await() } withTimeout(10_000) { connected.await() }
client.send(Gesture(gesture = "DoubleClick")) client.send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
awaitTrue { server.received.any { it is Gesture } } awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
val gesture = server.received.filterIsInstance<Gesture>().first() val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
glassesToHost(it) as Gesture
}
assertEquals("DoubleClick", gesture.gesture) assertEquals("DoubleClick", gesture.gesture)
client.close() client.close()
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
try { try {
runBlocking { runBlocking {
val client = GlassesWsClient(server.url()) val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>() val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>() val connected = CompletableDeferred<Unit>()
val job = launch { val job = launch {
client.connect( client.connect(
onHostMessage = { messages.add(it) }, onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) }, onConnected = { connected.complete(Unit) },
onDisconnected = {},
) )
} }
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
server.broadcast(SetMode(GlassesMode.CHAT)) server.broadcast(SetMode(GlassesMode.CHAT))
awaitTrue { messages.any { it is SetMode } } awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
val setMode = messages.filterIsInstance<SetMode>().first() val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
assertEquals(GlassesMode.CHAT, setMode.mode) assertEquals(GlassesMode.CHAT, setMode.mode)
client.close() client.close()
@@ -199,6 +214,50 @@ class GlassesWsClientTest {
} }
} }
/**
* Проверка самого [GlassesTransport]: обмен — сырыми JSON-строками —
* и на стороне отправки (encode вызывающий), и на стороне приёма.
*/
@Test
fun transportApiExchangesRawJsonStrings() {
val server = TestServer()
try {
runBlocking {
val transport = WifiGlassesTransport(server.url())
val rawMessages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
transport.connect(
onMessage = { rawMessages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
assertEquals("wifi", transport.name)
transport.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.3.0")))
awaitTrue { server.receivedRaw.any { it.contains("\"hello\"") } }
val helloJson = server.receivedRaw.first { it.contains("\"hello\"") }
val hello = glassesToHost(helloJson) as Hello
assertEquals("0.3.0", hello.appVersion)
// исходящее событие — тоже сырая строка; декод — за вызывающим
// (первый Welcome(MOVIE) — автоответ сервера на Hello; ждём наш CHAT)
server.broadcast(Welcome(mode = GlassesMode.CHAT))
awaitTrue { rawMessages.any { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT } }
val chatted = rawMessages.first { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT }
assertEquals(GlassesMode.CHAT, (hostToGlasses(chatted) as Welcome).mode)
transport.close()
job.cancelAndJoin()
}
} finally {
server.stop()
}
}
@Test @Test
fun reconnectsAfterServerDrop() { fun reconnectsAfterServerDrop() {
val server = TestServer() val server = TestServer()
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
val client = GlassesWsClient(server.url()) val client = GlassesWsClient(server.url())
val job = launch { val job = launch {
client.connect( client.connect(
onHostMessage = {}, onMessage = {},
onConnected = { connects++ }, onConnected = { connects++ },
onDisconnected = {},
) )
} }
+4
View File
@@ -16,6 +16,10 @@ dependencyResolutionManagement {
maven(url = "https://jitpack.io") maven(url = "https://jitpack.io")
mavenCentral() mavenCentral()
google() google()
// Домашний Nexus: личные Android/KMP-библиотеки (rayneo-mercury и др.)
maven(url = "http://192.168.76.117/repository/caffeine/") {
isAllowInsecureProtocol = true
}
} }
} }