75 Commits
1 .. dev

Author SHA1 Message Date
subochev e81026b9f5 Слои записи и оверлей берут стт-статус и сессию из единого GlassesStt 2026-09-07 04:27:05 +03:00
subochev 7db0fc71de Слои записи и оверлей берут стт-статус и сессию из единого GlassesStt 2026-09-07 03:59:15 +03:00
subochev 8083978849 Слой записи владеет микрофоном и распознаванием, движок — только диалогом 2026-09-07 03:27:31 +03:00
subochev 1b817aa9a6 Сохраняем прогресс просмотра и возобновляем с позиции 2026-09-07 01:09:44 +03:00
subochev 79a500150d Пейринг RayNeo: кнопка ручного (без QR) ре-паринга + debug-роуты + keep-screen-on
- MercuryBridge.pairClassicAuto: классический discovery → найти RayNeo по
  имени → createBond (без QR и без камеры очков) для случая «бонд слетел,
  очки в first-pairing». discoverGlasses — список найденных.
- MercuryPanel: кнопка «🔗 паринг (вручную)» рядом с QR-парингом
- DebugHttpServer: /mercury/pair, /mercury/pair/stop, /mercury/pair/classic,
  /mercury/unpair, /mercury/bonded, /mercury/discover, поля pairing в /status
- keep-screen-on, чтобы QR/состояние реально были на экране телефона
2026-09-04 03:08:38 +03:00
subochev f68ea10342 Сборка release: подпись ключом Binom (v2+v3) + таск assembleSigned
signingConfigs в app-phone/app-glasses берут кестор и креды из скилла
android-sign (вне git); без кестора release собирается без подписи.
assembleSigned — собрать оба приложения одним шагом.
2026-09-04 03:08:28 +03:00
subochev 5c86ed00b4 STT: очки распознают речь локально и шлют готовый текст (SttText) в телефон
- glasses: локальный ASR через общий pw.binom.asr (GlassesAsr/GlassesSttSession/SttModelProvider)
- protocol: SttText — очки присылают распознанный текст в телефон; телефон кидает его в LLM
  (старый SttAudio/StopStt оставлен фолбэком для старых очей)
- phone: SttStreamer делегирует общему ASR; GlassesServer роутит SttText в LLM
- lib-core: AudioPrep общий
2026-09-04 03:08:26 +03:00
subochev 0b3f9ff975 Mirror: медленный list(500) не роняет каталог и не дёргается в цикле
- отдельный defaultMirrorHttpClient (connect 15с / request 90с) + 1 повтор на list():
  тяжёлый GET /api/mirror?limit=N на сервере дольше общего 30с-таймаута и подёргивается (503);
- каталог: зеркала грузятся в отдельном try — сбой/таймаут не чистит уже загруженный каталог,
  просто временно без бейджей;
- детали: убрали тяжёлый list(limit=500) каждые 3 с (progress зеркала на сервере не живой, 0
  до конца) — в цикле опрашивается только дешёвый mirrorByItem.
2026-09-02 23:02:56 +03:00
subochev 40723fa584 Каталог: массовые действия (конвертировать/скачать/удалить), фильтр «только скачанные» и BackHandler вложенных экранов 2026-09-02 22:38:54 +03:00
subochev 728116662e Добавляем WatchProgressStore (watchprogress.json) и его тесты 2026-09-02 22:38:30 +03:00
subochev 0cfd92a68f Добавляем экран «Активность»: что сейчас конвертируется и скачивается 2026-09-02 22:37:31 +03:00
subochev f656f43e10 Сохраняем прогресс просмотра и возобновляем с позиции 2026-09-02 20:55:00 +03:00
subochev 1b28760c6e Добавляем в бейдж каталога статусы загрузки и готовности видео на очки 2026-09-02 19:53:21 +03:00
subochev b2e2d2697d Добавляем индикатор рассинхронизации звука и видео в статус-панели очков 2026-09-02 19:27:11 +03:00
subochev 92b483a5e3 Добавляем свёрнутый блок результата тула в чат с раскрытием по тапу 2026-09-02 02:49:48 +03:00
subochev edf7cf63f6 Внедряем единый тул-канал для локального LiteRT и удалённого LLM-клиента 2026-09-02 02:42:00 +03:00
subochev d5e60a2565 Заменяем litertlm-android 0.14.0 на KMP-обёртку litert-google 3 2026-09-01 18:46:18 +03:00
subochev 4587e22b50 Приложение добавляет OpenAI-совместимый HTTP-сервер и KMP-обёртку LiteRT 2026-09-01 18:45:25 +03:00
subochev e18b82642f audio-sync: учитывать латентность линка очки→телефон
Очки штампуют sentTs в PlaybackPosition; телефон мерит one-way
латентность (receivedTs - sentTs, EWMA) и добавляет её к мастер-позиции
в sync(), снимая системный перекос, из-за которого аудио недокручивало
на величину транспортной задержки. Плюс правки тестов под
HARD_SEEK_THRESHOLD_MS=5000 (остались незакомиченными с прошлого фикса).
2026-08-30 05:08:37 +03:00
subochev b11879520e fix(phone): поднимаем порог жёсткого seek аудио 2000→5000 мс
Жёсткий seekTo при |audio-video|>2000мс перебуферизировал ExoPlayer и давал
слышимую тишину (0.5-1с) + прыжок позиции. На локальном файле обычная буферная
пауза вывода пробивала порог и зацикливала seek→пауза. Порог 5000мс: умеренный
рассинхрон закрывается плавной коррекцией скоростью (бесшумно). Плюс диагностика:
лог типа источника (локальный файл/стриминг S3) и STATE_BUFFERING ExoPlayer,
и пик diff в логе seek.
2026-08-30 04:50:37 +03:00
subochev 3af74851db feat(mercury): синхронизировать часы очков с телефоном на подключении
RayNeoMercury.syncTime() шлёт текущее время телефона на очки. Вызываем
на событии Connected, чтобы очки получали корректную метку сразу после
подключения — это устраняет первопричину клина лог-спула (очки стартуют
с ~1970 до NTP, Loki отвергает batch с устаревшим таймстемпом, 400).
2026-08-30 03:42:47 +03:00
subochev 1aa389a3a0 fix(log): разблокировать спул лог-коллектора при устаревших таймстемпах
Раньше одна пачка в спуле с таймстемпом вне окна приёма Loki (≈7 суток)
топила весь пуш: Loki отвечал 400 на весь batch, спул никогда не чистился,
и новые логи (в т.ч. телефона) не доходили. Теперь flush() выгребает ВСЕ
готовые пачки за тик (FIFO) и отбрасывает записи вне окна Loki, а при
неудаче отправки спула буфер не трогается (ждёт следующий тик). Добавлены
тесты на очистку устаревших записей и FIFO-ретрай спула.
2026-08-30 03:30:39 +03:00
subochev 6cd4a8de43 feat: управление яркостью/WiFi очков через Mercury + накопленные доработки
- Mercury (app-phone): управление яркостью (с авто-выкл. перед
  применением) и WiFi (скан/подключение/список сетей с паролем);
  добавлено право BLUETOOTH_ADVERTISE в манифест и runtime-запрос
  (без него GATT-сервер не рекламируется → очки не подключаются)
- логирование (lib-core core.log): BatchingLogCollector + LogSink +
  LogSpool + LokiLogSink; цепочка очки→телефон→Loki; lib-core log()
  переадресован в коллектор
- STT: Qwen3-ASR через pw.binom.asr:asr-qwen3-android, убран in-house
  whisper-фоллбек
- audio-sync: P-регулятор с насыщением вместо фиксированной скорости
- media-split: очки только video, телефон только audio (UI-чипы)
- Components: форматирование размера через Locale.ROOT
2026-08-29 23:06:52 +03:00
subochev 62d2f06797 soft sync audio via PlaybackParameters speed (rate-based, no seekTo jump)
Master = glasses (5s PlaybackPosition), slave = phone audio.
Replace aggressive seekTo-drift-corrector with smooth tempo correction:
- |diff|<=150ms (TRIGGER_BAND) -> speed 1f (hysteresis, no flutter)
- audio lags -> speed 1.02 (catch up)
- audio ahead -> speed 0.98 (slow down)
- |diff|>2000ms -> rare hard seekTo + reset speed 1f
- removed catch-up seekTo after +1s (was adding ~2s error)
- log now prints speed
AudioSyncPlayerTest: 18 cases (boundaries 150/2000, speed up/down/1f, play/pause).
2026-08-27 23:23:50 +03:00
subochev cf8c5ef604 ui(phone): переключение сессий ступенчатым свайпом (порог 120dp, ±1 сессия за жест) + крупнее текст сообщений (14→20sp) 2026-08-27 18:46:31 +03:00
subochev 236ff18543 ui(glasses): оверлей ассистента накрывает весь вьюпорт (fillMaxSize, непрозрачный чёрный) — независимо от масштаба и регулятора «Высота» видео 2026-08-27 18:46:31 +03:00
subochev 412e093174 ui(glasses): отмена первой записи не закрывает оверлей — возврат в «слушаю», диалог жив даже при пустой истории; выход из режима — только явный жест ExitMode 2026-08-27 18:46:29 +03:00
subochev da8182443c stt(phone): SttDebug переведён на Qwen3-ASR-0.6B (отладочный прогон wav) 2026-08-25 21:30:09 +03:00
subochev 17385d1784 stt(phone): Qwen3-ASR-0.6B int8 вместо whisper-small (константа STT_BACKEND, hotwords тулсет/тулы/умеешь) — откат в одну строку (opencode по ТЗ) 2026-08-25 19:57:33 +03:00
subochev 98ac84ffe8 stt(phone): сегментация буфера на фразы перед whisper (лимит ≤30с) — длинные сессии распознаются целиком, фразы склеиваются пробелом (opencode по ТЗ) 2026-08-25 18:09:56 +03:00
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
159 changed files with 15231 additions and 935 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 — собирается
+119
View File
@@ -0,0 +1,119 @@
# TASK: сегментация STT-буфера на фразы (whisper ≤30с)
## Проблема (доказана на железе 25.08, полевым тестом ac56e70)
sherpa-onnx OfflineRecognizer (whisper-small int8) принимает волны ТОЛЬКО ≤ 30 с:
лог движка «Only waves less than 30 seconds are supported. We process only the
first 30 seconds and discard the remaining data». При слитном буфере сессии
(полевой тест: 117.6 с после trimEdges) whisper обработал первые 30 с (тишина)
и выдал галлюцинации («Убираем. Убираем…», «Хуйню ты на распознавал»), а вся
речь после 30 с молча выброшена. Проверено на хосте тем же движком
(sherpa-onnx 1.13.6): тот же raw, нарезанный скользящим окном 30 с (шаг 15 с),
распознаётся чисто: «Я говорю что ты умеешь? Какие тебе доступны тулы? Ну
давай, а что из этого тулсета у тебя есть? Раз, два, три, четыре, пять».
## Решение
Перед распознаванием резать буфер на фразы по паузам тишины (≥ 0.6 с),
распознавать каждую фразу отдельно (гарантированно ≤ 30 с), склеивать
результаты через ПРОБЕЛ (решение пользователя, дословно: «пробелами надо
разделять»).
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt`
— новая чистая функция `splitPhrases` + правка `recognizeFullBufferLocked`.
2. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt`
— НОВЫЙ файл, JVM-тесты `splitPhrases`.
3. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt`
— добавить ОДИН кейс «две фразы через паузу → распознаны раздельно, склеены
пробелом» (переиспользовать приватный `RecordingRecognizer` того же класса).
ЗАПРЕЩЕНО трогать: `SttMicStream.kt`, `WhisperStt.kt`, `trimEdges` (кроме
вызова), `GlassesServer.kt`, `PhoneApp.kt`, `lib-core`, протокол, весь
app-glasses. Все scratch-файлы — внутри репо, в коммит не включать.
## 1. splitPhrases — чистая internal функция в SttStreamer.kt
```kotlin
/**
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
* НЕ менять без перепроверки на тех же дампах.
*/
internal fun splitPhrases(
samples: FloatArray,
threshold: Float = 0.005f,
windowSize: Int = 320, // 20 мс @ 16 кГц
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
): List<FloatArray>
```
Семантика (точная, из полевого разбора):
- Окно [windowSize] сэмплов — ТИШИНА, если RMS < threshold.
Порог 0.005, НЕ 0.01 как в trimEdges: 0.01 режет тихие начала слов на
обрубки, whisper на обрубке выдаёт мусор. На усиленном ×3 потоке шум
~0.005–0.009, речь ~0.03–0.14.
- Пауза = подряд ≥ minSilenceWindows тихих окон (0.6 с). Пауза РАЗДЕЛЯЕТ фразы.
- Фраза = звуковые окна между паузами + отступ padWindows тихих окон с каждой
стороны (clamp к границам массива, как в trimEdges).
- Фраза короче minPhraseWindows (1.5 с): объединить с СОСЕДНЕЙ фразой (короткий
сегмент = обрубок; whisper на нём мусорит). Единственная фраза — оставить
как есть.
- Фраза длиннее maxPhraseSamples (30 с): резать жёстко на куски ≤ 30 с по
границе окна (sherpa-onnx молча обрезает длинную волну — страховка обязательна).
- Пустой вход / всё тишина → пустой список.
- Внутренние паузы < 0.6 с НЕ трогать (внутри фразы тишина остаётся: склейка
пауз ломает whisper — «де-тишина» давала «1,2,3,4,5,8,10», проверено).
## 2. recognizeFullBufferLocked — правка
Текущий код (строки ~187–211 SttStreamer.kt): `trimEdges(base)` → ОДИН вызов
`stt.recognize(chunk)`.
Новый поток:
1. `val chunk = trimEdges(base)` — как сейчас, НЕ менять.
2. `val phrases = splitPhrases(chunk)`.
3. `phrases` пуст → `return` (как сейчас при пустом chunk).
4. В executor-задаче: для КАЖДОЙ фразы последовательно `stt.recognize(phrase)`;
результат с `isBlank()` — пропускать (мусорный сегмент, в текст не вставлять);
итог — `joinToString(" ")` по непустым результатам.
5. Все фразы пустые → `full` НЕ перезаписывать (семантика finish без текста
сохраняется, вызывающий сам решит).
6. Лог: `полный буфер: N фраз (2.3с, 4.1с, …) → '$full'` — размеры фраз в
секундах, один знак после запятой.
7. `task.get(...)`: таймаут поднять с 30 до 60 секунд (N фраз × до 30 с каждая).
## 3. Тесты
PhraseSplitTest.kt — чистые JVM-тесты (синтез сигнала в стиле FullBufferSttTest:
зубчатая волна как «речь», нули как тишина; для «тихих начал» — плавное
нарастание амплитуды):
1. Две фразы (по ~2 с), пауза 0.7 с → ровно 2 элемента; обе начинаются с
звучного окна (не с тишины).
2. Пауза 0.4 с (< 0.6) НЕ разделяет → 1 элемент.
3. Тихие начала слов не режутся: фраза с окнами 0.003–0.009 на старте остаётся
целой (порог 0.005) → 1 элемент, начало фразы включает тихие окна.
4. Всё тишина → пустой список.
5. Пустой вход → пустой список.
6. Фраза 31 с → разрезана на 2 куска, каждый ≤ 30 с.
7. Две короткие фразы (0.8 с и 0.9 с, пауза 0.7 с) → объединены в одну.
FullBufferSttTest.kt — добавить кейс (внутри класса, до закрывающей `}`):
- буфер: «фраза А» (1.5 с зуба), пауза 0.7 с нулей, «фраза Б» (1.5 с зуба);
`RecordingRecognizer("А", "Б")`; после `finish()`: `calls.size == 2`,
`full == "А Б"` (пробел — разделитель по решению пользователя).
## Проверка (делает Hermes, не агент)
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
число тестов app-phone выросло на 8 (7 новых + 1 дополненный).
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
+158
View File
@@ -0,0 +1,158 @@
# TASK: замена whisper-small на Qwen3-ASR-0.6B int8 (sherpa-onnx, CPU)
## Проблема (доказана на железе 25.08, полевым тестом ac56e70 + серверным экспериментом)
whisper-small int8 распознаёт русскую речь посредственно: «Какие тебе доступные
толулы?», «Ну давай, а что из этого туалета у тебя есть?» (было на дампах
пользователя). Qwen3-ASR-0.6B (тот же движок sherpa-onnx 1.13.6, тот же CPU)
без подсказок — ровня висперу, а с hotwords («тулсет,тулы,умеешь») исправляет
все провалы в ноль: «Какие тебе доступные тулы?», «Ну давай, а что из этого
тулсета у тебя есть?», «Что ты умеешь?». Эксперимент проведён на дампах
пользователя тем же движком и той же моделью (int8), скорость на серверном CPU
0.1-0.2 от реального времени (фраза 4.4 с → 0.9 с). Решение пользователя:
остаёмся на CPU (аппаратное ускорение NNAPI для трансформеров не даёт выигрыша).
## Решение
Новый класс `Qwen3AsrStt` (реализует тот же интерфейс `PhraseRecognizer`, что и
`WhisperStt`) + переключатель-константа в фабрике `PhoneApp.ensureStt()`.
Старый `WhisperStt` НЕ удалять — откат в одну строку. Сегментация по паузам
(`splitPhrases` + склейка пробелом в `SttStreamer`) НЕ меняется — Qwen3-ASR тоже
имеет лимит max_total_len=512 токенов (≈ 30-40 с аудио), нарезка остаётся в силе.
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt` — НОВЫЙ файл.
2. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt` — только
`ensureStt()` (строки ~350-366) + 2 константы в companion object (строка 512).
ЗАПРЕЩЕНО трогать: `WhisperStt.kt`, `SttStreamer.kt`, `SttDebug.kt`,
`GlassesServer.kt`, `lib-core`, протокол, весь app-glasses. Все scratch-файлы —
вне репо или в коммит не включать.
## 1. Qwen3AsrStt.kt — новый класс
```kotlin
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
class Qwen3AsrStt(
private val convFrontendPath: String,
private val encoderPath: String,
private val decoderPath: String,
private val tokenizerDir: String,
private val numThreads: Int = 4,
private val hotwords: String = "",
) : PhraseRecognizer {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
qwen3Asr = OfflineQwen3AsrModelConfig(
convFrontend = convFrontendPath,
encoder = encoderPath,
decoder = decoderPath,
tokenizer = tokenizerDir,
maxTotalLen = 512,
maxNewTokens = 128,
temperature = 1e-6f,
topP = 0.8f,
seed = 42,
hotwords = hotwords,
),
numThreads = numThreads,
provider = "cpu",
modelType = "qwen3_asr",
),
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}
```
Точные имена полей и сигнатуры подтверждены байткодом sherpa-onnx v1.13.6
(`OfflineQwen3AsrModelConfig` — поля convFrontend/encoder/decoder/tokenizer/
maxTotalLen/maxNewTokens/temperature/topP/seed/hotwords; `OfflineModelConfig` —
поле qwen3Asr; нативная строка `qwen3_asr=` подтверждает modelType).
Дефолты декодирования — из C++-конфига sherpa-onnx 1.13.6: max_total_len=512,
max_new_tokens=128, temperature=1e-6, top_p=0.8, seed=42. Поле `tokens` в
OfflineModelConfig для qwen3_asr НЕ передавать (токенизатор — директория в
qwen3Asr.tokenizer, не файл).
## 2. PhoneApp.kt — фабрика и константы
В companion object (строка 512) добавить:
```kotlin
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
private const val STT_BACKEND = "qwen3_asr"
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
```
В `ensureStt()` (строки ~350-366) — заменить блок создания whisper на:
```kotlin
val qwenConv = File(modelsDir, "conv_frontend.onnx")
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
val qwenDec = File(modelsDir, "decoder.int8.onnx")
val qwenTok = File(modelsDir, "tokenizer")
if (STT_BACKEND == "qwen3_asr" &&
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
) {
stt = Qwen3AsrStt(
convFrontendPath = qwenConv.absolutePath,
encoderPath = qwenEnc.absolutePath,
decoderPath = qwenDec.absolutePath,
tokenizerDir = qwenTok.absolutePath,
numThreads = 4,
hotwords = STT_HOTWORDS,
)
} else {
val encoder = File(modelsDir, "small-encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt")
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null
}
stt = WhisperStt(
encoderPath = encoder.absolutePath,
decoderPath = decoder.absolutePath,
tokensPath = tokens.absolutePath,
numThreads = 4,
)
}
```
Тип `stt` — `PhraseRecognizer` (локальная переменная в synchronized-блоке;
дальше `SttStreamer(stt = stt, ...)` как сейчас). Лог создания: добавить строку
`log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")` перед созданием
SttStreamer. Старая ветка whisper остаётся для отката.
## Проверка (делает Hermes, не агент)
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
число тестов app-phone НЕ меняется (новый класс не покрывается JVM-тестами —
Android + нативная библиотека; splitPhrases/trimEdges не трогаются).
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
- Дальше Hermes сам: adb push моделей (conv_frontend.onnx, encoder.int8.onnx,
decoder.int8.onnx, tokenizer/ — из /root/viewmate/models/sherpa-onnx-qwen3-asr-0.6B-int8/)
на телефон в filesDir/models + установка APK + полевая приёмка на железе.
+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.
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
+84
View File
@@ -0,0 +1,84 @@
# TASK: мягкая синхронизация аудио через скорость (rate-based sync)
## Контекст / проблема
В `view-mate` звук играет на **телефоне** (ExoPlayer media3), видео — на **очках** (очки — мастер, шлют позицию каждые 5с через `PlaybackPosition`). Телефон подстраивает аудио под позицию очков.
Сейчас коррекция в `AudioSyncPlayer.sync()` грубая:
- раз в 5с (на каждый PlaybackPosition от очков) вызывается `sync(positionMs, isPlaying)`;
- если `abs(audioPos - videoPos) > 1000` → **`seekTo(videoPos)`** — резкий рывок;
- после seek ещё «догонка» через 1с (второй `seekTo`).
Проблема: звук на телефоне стабильно уезжает на ~1с вперёд от видео очков (статика `diff=1000` в паузе; при живом просмотре набирает >1000). Грубый `seekTo` при каждом пересечении порога рождает **повтор фразы** («нас заметили … нас заметили») и кодечки — слышимый дефект.
## Задача
Заменить резкий `seekTo`-корректор на **мягкую коррекцию через темп воспроизведения (PlaybackParameters.speed)**. Очки остаются мастером, телефон — ведомым: аудио подстраивается под позицию очков плавно, без рывков и повторов.
## Конкретные требования
### 1. Чистая функция решателя (заменить `decideSync`)
Переписать `decideSync` (сейчас возвращает `SyncDecision(seekToMs, playing)`) так, чтобы он возвращал **скорость** и лишь экстремально — seek:
```kotlin
internal data class SyncDecision(
val speed: Float = 1f, // желаемая скорость аудио
val seekToMs: Long? = null, // только при большом рассинхроне (> HARD_SEEK_THRESHOLD)
val playing: Boolean? = null,
)
```
Правила (пороги — константами, из названий ясны):
- `abs(diff) <= SOFT_DRIFT_MS` (например 150мс) → связь «в норме»: `speed = 1f`.
- `audioPos < videoPos` (звук отстаёт) → подгонять **вперёд**: `speed = 1f + DRIFT_RATE` (например `1.02`).
- `audioPos > videoPos` (звук впереди) → подгонять **назад**: `speed = 1f - DRIFT_RATE` (например `0.98`).
- `abs(diff) > HARD_SEEK_THRESHOLD` (например 2000ms) → **`seekToMs = videoPos`** (рассинхрон большой, скорость не нагонит вовремя) — но это редкость.
- `playing` — как раньше (догон play/pause по состоянию мастера).
Учесть гистерезис: не менять направление скорости слишком часто. Например, помнить последнее направление и не переключать на «назад/вперёд» чаще чем раз в N (или не переключать, пока diff не станет > небольшой зоны около 0). Мин. простой вариант: **TRIGGER_BAND_MS** — скорость меняется/выравнивается только когда diff за пределами зоны `[-TRIGGER_BAND, +TRIGGER_BAND]` вокруг 0 (например 150ms); внутри зоны — `1f`. Это убирает дребезг.
### 2. Применение в `AudioSyncPlayer.sync()`
- Вызов `decideSync` с новым набором параметров.
- Если `decision.seekToMs != null` — текущий `seekTo` (редкий случай, большой рассинхрон) + восстановить `speed=1f` после seek (seek сбрасывает позицию, дальше мягко).
- Иначе (обычный путь): `player.setPlaybackParameters(PlaybackParameters(speed))` — **без `seekTo`**.
- Убрать «догонку через 1с» (второй резкий seek) — при мягкой коррекции она не нужна и вредна.
- `lastVideoPositionMs`, `lastVideoPlaying` — сохранить семантику из текущего.
Требование: **все операции ExoPlayer по-прежнему на main-потоке** (уже есть `main.post`). `setPlaybackParameters` вызывать внутри `main.post`.
### 3. Логирование
- В `sync` логировать теперь скорость: `audio sync: video=N audio=M diff=K speed=S` (S — применяемая скорость или 1f). Полезно для QA.
- При seek (редкий) — как сейчас `audio sync: seek → N`.
### 4. Тесты (обязательно)
Переписать/добавить unit-тесты на новую `decideSync` (и/или rename в `decideSpeed`/`decideAdjustment`):
- `audioPos` близко к `videoPos` (diff < порога мягкой зоны) → `speed == 1f`, `seekToMs == null`;
- `audioPos` отстаёт (audio < video, диф в зоне до HARD) → `speed > 1f`, без seek;
- `audioPos` впереди (audio > video, в зоне до HARD) → `speed < 1f`, без seek;
- `abs(diff) > HARD_SEEK_THRESHOLD` → `seekToMs == videoPos`, speed по-прежнему 1f (после seek мягко подогнать);
- `playing` (play/pause) отдельно — без влияния на speed;
- boundary: ровно на границах порога (equals) — чёткое поведение.
Проверить, что старые тесты `AudioSyncPlayerTest` переписаны под новый контракт (скорость вместо seek в обычных случаях).
### 5. Не трогать фильмы/прошлое поведение
- На очках ничего менять (мастер позиции остаётся).
- `play(url, startPositionMs)` — оставить: начальная позиция аудио при старте — `startPositionMs`, потом мягкая подгонка.
- Суть: только `_phone_`/`AudioSyncPlayer` меняется.
## Файлы
- `app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt` (основной).
- `app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt` (тесты).
## Требования к исполнению / команды
- Сборка и прогон тестов только через **локальный gradle** в репо (не трогать чужой infra).
- Прогнать: `./gradlew :app-phone:testDebugUnitTest` (или `:app-phone:test` в исходном тесте — уточнить task по проекту) — тесты модуля phone проходят.
- Код только на **Kotlin**, в стиле проекта (см. соседние файлы). Без лишних зависимостей.
- НЕ коммитить сами — собрать и прогнать тесты, показать диф. (Коммит — после подтверждения здоров: образец ты решаешь/проверяешь сам.)
+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 НЕ делать.
+117 -91
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, агрессивная фоновая
политика) замораживает/убивает процесс:
1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
## Решение
Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK +
WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране
телефона. Очки тогда не теряют связь, видео не паузится, звук играет.
### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`)
```xml
<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>` — сервис:
```xml
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
```
### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
```kotlin ```kotlin
fun ConnectionScreen() { class PlaybackService : Service() {
val connection = GlassesApp.instance.hostConnection companion object {
val connected by connection.connected.collectAsState() const val CHANNEL_ID = "viewmate_playback"
val status by connection.status.collectAsState() const val NOTIFICATION_ID = 1
val messages by connection.messages.collectAsState() // аккуратный запуск: ContextCompat.startForegroundService(context, intent)
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ fun start(context: Context) {
ContextCompat.startForegroundService(
Binocular(scale = false) { context, Intent(context, PlaybackService::class.java)
val screenWidthPx = BinocularScreenSize.current.width )
val screenHeightPx = BinocularScreenSize.current.height }
// масштаб через layout — как в GlassesMovieScreen fun stop(context: Context) {
val containerW = (screenWidthPx * scale).toInt() context.stopService(Intent(context, PlaybackService::class.java))
val containerH = (screenHeightPx * scale).toInt()
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = offsetX.px)
.background(Color.Black),
) {
... существующий контент ...
} }
} }
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
} }
``` ```
Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose, ### 3. Запуск/остановка сервиса в `PhoneApp.kt`
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри - **Старт — при каждом старте аудио** (3 места):
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер - `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192. - `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen (строка ~127) и второй play (строка ~215)
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в - ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent, `PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
там есть `Side.LEFT/RIGHT` через CompositionLocal). приложение живо) — НО только если пользователь не против постоянного
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
уведомление — постоянный индикатор «ViewMate работает». Это проще и
покрывает и watch, и autostartAudio, и STT-сессии.
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
## Баг 2: скролл каталога теряется при навигации Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
приложения-компаньона — так работает, например, в паре с очками).
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад ### 4. Проверка
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
папку, потом ещё — и т.д.»
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`: 1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится. 2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит. 3. На железе (через 142): установить APK, запустить, начать просмотр,
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
**Фикс:** добавить в `CatalogState`: проверить: звук играет, видео на очках играет (позиция растёт в логах),
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
- `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
val gridState = rememberLazyGridState()
// восстанавливаем позицию при смене пути/загрузке
LaunchedEffect(state.path, state.items) {
val (idx, off) = state.savedScrollForCurrentPath()
gridState.scrollToItem(idx, off)
}
// сохраняем позицию при скролле
LaunchedEffect(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
}.collect { (idx, off) -> state.setScroll(idx, off) }
}
```
и `LazyVerticalGrid(state = gridState, ...)`.
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
каждый уровень сохраняет свою позицию.
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
`androidx.compose.runtime.snapshotFlow`.
## Критерии приёмки
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
+47
View File
@@ -1,3 +1,5 @@
import java.io.File
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -5,6 +7,20 @@ plugins {
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
} }
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
val binomCertsDir: File = File(
(project.findProperty("binom.keystore.dir") as? String)
?: System.getenv("BINOM_KEYSTORE_DIR")
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
)
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
val binomCreds: Map<String, String> =
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
else emptyMap() })
android { android {
namespace = "pw.binom.viewmate.glasses" namespace = "pw.binom.viewmate.glasses"
compileSdk = 35 compileSdk = 35
@@ -17,9 +33,30 @@ android {
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0" versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
} }
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
// Без кестора binomCreds пусто → release остаётся неподписанным.
if (binomCreds.isNotEmpty()) {
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
signingConfigs {
create("binom") {
storeFile = binomStoreFile
storePassword = binomStorePass
keyAlias = binomAlias
keyPassword = binomKeyPass
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
} }
} }
@@ -61,6 +98,16 @@ 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")
// Локальный Qwen3-ASR на очках (движок + распознавание переезжают на очки из app-phone).
// asr-qwen3-android тянет sherpa-onnx (nativ) транзитивно; модель — в filesDir/models
// (SttModelProvider), как на телефоне.
implementation("pw.binom.asr:asr-qwen3-android:4")
// 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,191 @@
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-пермишены, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12) + RECORD_AUDIO:
* микрофон локального STT — без него AudioRecord не стартует и сессия
* молча падает в файловую эмуляцию (пустой буфер).
* 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)
}) + Manifest.permission.RECORD_AUDIO
@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
}
}
@@ -0,0 +1,182 @@
package pw.binom.viewmate.glasses
import android.view.MotionEvent
import androidx.compose.runtime.Composable
import com.rayneo.arsdk.android.touch.FlingArgs
import com.rayneo.arsdk.android.touch.TempleAction
/**
* Дискретный жест трекпада. Плавного (continuous) жеста нет: [TempleAction.SlideContinuous]
* по знаку дельты сворачивается в дискретный свайп ([toGesture]) — плавной прокрутки диалога
* ассистента больше не нужно.
*/
enum class Gesture { Click, DoubleClick, TripleClick, SwipeForward, SwipeBackward }
/**
* SDK-событие трекпада → дискретный [Gesture]. Возвращает null, если жест игнорируется
* (MoveUp/ActionUp/Idle/LongClick). [TempleAction.SlideContinuous] → дискретный свайп по знаку
* дельты (>=0 вперёд).
*/
fun TempleAction.toGesture(): Gesture? = when (this) {
is TempleAction.Click -> Gesture.Click
is TempleAction.DoubleClick -> Gesture.DoubleClick
is TempleAction.TripleClick -> Gesture.TripleClick
is TempleAction.SlideForward -> Gesture.SwipeForward
is TempleAction.SlideBackward -> Gesture.SwipeBackward
is TempleAction.SlideContinuous -> if (this.delta >= 0f) Gesture.SwipeForward else Gesture.SwipeBackward
else -> null
}
/**
* Обратный [toGesture] — [Gesture] → SDK-событие, чтобы маршрутизацию layout-а прогнать через
* единственную матрицу [pw.binom.viewmate.glasses.gestures.gestureDecision] (она ждёт
* [TempleAction]). Плавного жеста нет — дискретные свайпы.
*/
fun Gesture.toAction(): TempleAction = when (this) {
Gesture.Click -> TempleAction.Click()
Gesture.DoubleClick -> TempleAction.DoubleClick()
Gesture.TripleClick -> TempleAction.TripleClick()
Gesture.SwipeForward -> TempleAction.SlideForward(DUMMY_FLING)
Gesture.SwipeBackward -> TempleAction.SlideBackward(DUMMY_FLING)
}
/**
* Плейсхолдер [FlingArgs] для свайпов в [toAction]: матрица [gestureDecision] смотрит только
* тип жеста, args свайпа ей не нужны, поэтому одна переиспользуемая заглушка (один
* [MotionEvent] на всё время — pool-утечки нет).
*/
private val DUMMY_FLING: FlingArgs = run {
val e = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0)
FlingArgs(e, e, 0f, 0f)
}
/**
* Экран/слой очков. Рисуется композитом в стек [LayoutManager]: снизу вверх, между слоями
* затемнение. [gesture] вызывается ТОЛЬКО вершиной стека — [LayoutManager.dispatch] маршрутизирует
* каждый жест в [Layout.gesture] активного (верхнего) layout-а, остальные слои жест не получают.
*/
interface Layout {
/** Основной экран слоя (видео/чат и т.п.). */
@Composable
fun drawStage()
/** Панель состояния: вызывается первой, потом стандартные время/заряд. */
@Composable
fun drawStatusPanel()
/** Обработка жестов данным слоем. Вызывается менеджером только для верхнего слоя. */
fun gesture(g: Gesture)
/**
* Слой открыт (встал вершиной стека) — менеджер вызывает сразу после [LayoutContext.open].
* Под-слой использует для своего «старта»: напр. [pw.binom.viewmate.glasses.ui.layers.ListeningLayout]
* здесь открывает микрофон. Корневой слой не получает (он не «открывается», а создаётся менеджером).
*/
fun onEnter() {}
/** Слой закрыт (POP-нут из стека) — менеджер вызывает в [LayoutContext.close]. Симметрично [onEnter]. */
fun onExit() {}
}
/**
* Контекст, доступный layout-у: открыть под-слой ([open]), закрыть себя ([close]), управлять
* глобальным индикатором записи ([setRecordingIndicator]). Индикатор горит красным, пока активен
* хотя бы один слой, включивший его; выключается, когда этот слой перестал быть активным (POP)
* или переключил его в false. Экземпляр создаёт [LayoutManager] — каждый layout получает один
* общий контекст (самого менеджера).
*/
interface LayoutContext {
fun setRecordingIndicator(on: Boolean)
/** Закрыть текущий (верхний) слой. Корневой слой закрыться не может. */
fun close()
/** Выйти до корня (корневой слой остаётся). */
fun closeToRoot()
/**
* Открыть новый слой поверх: менеджер сам создаст layout через [factory] и передаст ему
* контекст. Инвариант — только активный (верхний) слой открывает под-слой.
*/
fun open(factory: (LayoutContext) -> Layout)
}
/**
* ЛIFO-стек layout-ов очков (то, что пользователь называет «стек»): корень (двойным кликом не
* вытолкнуть) → сверху под-слои. Владеет стеком и контекстом одновременно — один объект, без
* «расщеплённого» context/manager. Корень создаётся [rootFactory], которому менеджер отдаёт сам
* себя как [LayoutContext] (корень открывает под-слои через [LayoutContext.open]).
*
* Композитинг — [compositing]: рисуются ВСЕ слои снизу вверх, между ними затемнение
* (например `фильм + затемнение + ассистент`). Жесты — [dispatch] в вершину.
*/
class LayoutManager(
rootFactory: (LayoutContext) -> Layout,
) : LayoutContext {
private val stack = ArrayDeque<Layout>()
private val recordingOn = linkedSetOf<Layout>()
/** Колбэк на каждую mутацию стека (open/close/closeToRoot) — для внешних проекций (напр. navStack). */
var onChange: (List<Layout>) -> Unit = {}
init {
stack.addLast(rootFactory(this))
onChange(stack.toList())
}
/** Верхний (активный) слой. */
val current: Layout get() = stack.last()
/** Корневой слой (не выталкивается; владелец медиастейта, напр. [MoveLayout]). */
val root: Layout get() = stack.first()
/** Глубина стека (1 = только корень). */
val depth: Int get() = stack.size
/** Стек снизу вверх. */
fun layersBottomUp(): List<Layout> = stack.toList()
/** Композит: каждый слой + флаг затемнения (затемнять каждый слой, кроме верхнего). */
data class Layer(val layout: Layout, val dim: Boolean)
/** Композит снизу вверх: film → dim → assistant. Верхний (последний) — без затемнения. */
fun compositing(): List<Layer> =
stack.mapIndexed { i, l -> Layer(l, i < stack.lastIndex) }
/** Маршрутизация жеста в верхний слой — единственная точка входа. */
fun dispatch(g: Gesture) {
current.gesture(g)
}
// LayoutContext:
override fun open(factory: (LayoutContext) -> Layout) {
val child = factory(this)
stack.addLast(child)
child.onEnter()
onChange(stack.toList())
}
override fun close() {
// Корневой слой не закрывается.
if (stack.size > 1) {
val popped = stack.removeLast()
popped.onExit()
recordingOn.remove(popped)
onChange(stack.toList())
}
}
/** Выйти до корня (корень остаётся). */
override fun closeToRoot() {
while (stack.size > 1) close()
}
override fun setRecordingIndicator(on: Boolean) {
val top = current
if (on) recordingOn.add(top) else recordingOn.remove(top)
}
/** Индикатор записи: горит, пока активен хотя бы один слой с включённым индикатором. */
fun recordingIndicator(): Boolean = recordingOn.isNotEmpty()
}
@@ -4,13 +4,40 @@ import android.app.Application
import android.content.Context import android.content.Context
import com.rayneo.arsdk.android.MercurySDK import com.rayneo.arsdk.android.MercurySDK
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.CoroutineScope
import pw.binom.viewmate.core.log.BatchingLogCollector
import pw.binom.viewmate.core.log.FileLogSpool
import pw.binom.viewmate.core.log.SystemClock
import pw.binom.viewmate.glasses.gestures.NavFrame
import pw.binom.viewmate.glasses.stt.GlassesStt
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
import pw.binom.viewmate.glasses.ui.layers.toFrame
/** Один элемент диалога в оверлее очков (роль 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
} }
/** /**
@@ -22,13 +49,42 @@ class GlassesApp : Application() {
lateinit var hostConnection: HostConnection lateinit var hostConnection: HostConnection
private set private set
lateinit var movieController: MovieController
private set
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */ /** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
lateinit var downloadManager: GlassesDownloadManager lateinit var downloadManager: GlassesDownloadManager
private set private set
/** Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте. */
lateinit var stt: GlassesStt
private set
/**
* Персистентный менеджер стека layout-ов (корень [MoveLayout] получает [LayoutContext] и
* контекст приложения для своего медиастейта). Единственный владелец стека: слои
* открывают/закрывают кадры через [LayoutContext.open]/[LayoutContext.close];
* [navStack] — проекция этого стека для оверлея.
*/
val layoutManager = LayoutManager { ctx -> MoveLayout(ctx, this) }
/** Корневой слой (владелец медиастейта): протокол/lifecycle/эффекты дотягиваются до
* [MovieController] через него ([rootLayer.controller]). */
val rootLayer: MoveLayout get() = layoutManager.root as MoveLayout
/**
* Движок ассистента: внятный стейт диалога (фазы/тексты/история) + его жестовые эффекты.
* Владелец стейта, живёт всё время приложения; слои [AssistantLayout]/[ListeningLayout]
* и оверлей дотягиваются до него, протокол/lifecycle пишут через [assistantEngine].
* Зависимости от [GlassesApp] (stt/hostConnection/rootLayer) — лениво, в методах.
*/
val assistantEngine = AssistantEngine(this)
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
lateinit var logCollector: BatchingLogCollector
private set
/** Скоуп фоновых корутин (таймер лог-коллектора). */
private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE) getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
} }
@@ -36,14 +92,30 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */ /** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
val scale = MutableStateFlow(1f) val scale = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */ /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val listening = MutableStateFlow(false) val vertical = MutableStateFlow(1f)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ // Стейт диалога ассистента (thinking/assistantText/chatHistory/answerScrollDelta + idle-hint
val sttText = MutableStateFlow("") // sttNoSpeech) — в [assistantEngine], владелец стейта ассистента. Микрофон и распознавание
// (sttText, старт/стоп стт-сессии) владеет слой [ListeningLayout]; «модель не скачалась» —
// из stt.status. Движок микрофоном не владеет.
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */ /**
val assistantText = MutableStateFlow("") * Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не
* вытолкнуть) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон, чат на фоне).
* Тройной клик в кино кладёт «ассистент» + «слушаю»; двойной клик POP-ит верхний кадр:
* из «слушаю» → в кадр ассистента (idle, микрофон закрыт, чат не прерван), из idle/
* «думаю»/«ответ» → в «фильм». Держится до явного выхода (двойной клик в кадре
* ассистента, 30с тишины, потеря фокуса) и сбрасывается в [AssistantEngine.exitMode].
*/
val navStack = MutableStateFlow(listOf(NavFrame.Film))
/**
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
* (видео на очках не играет / данных ещё нет).
*/
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
/** Текущая Activity (для закрытия приложения по команде с телефона). */ /** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null var mainActivity: MainActivity? = null
@@ -55,22 +127,57 @@ 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) // Медиадвижок живёт в корневом слое (rootLayer.controller), не в приложении.
movieController.eventLogger = { msg -> logToFile("mc: $msg") } rootLayer.controller.eventLogger = { msg -> logToFile("mc: $msg") }
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) } downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
hostConnection = HostConnection( hostConnection = HostConnection(
fallbackUrl = GlassesConfig.HOST_URL, fallbackUrl = GlassesConfig.HOST_URL,
movieController = movieController, movieController = rootLayer.controller,
downloadManager = downloadManager, downloadManager = downloadManager,
context = applicationContext, context = applicationContext,
) )
hostConnection.start() hostConnection.start()
log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}") log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}")
// Лог-коллектор: пачки → телефон; обрыв связи — спул в filesDir, ретраи таймером.
logCollector = BatchingLogCollector(
device = "glasses",
sink = GlassesLinkLogSink(hostConnection),
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 32),
clock = SystemClock(),
flushIntervalMs = 60_000,
maxBatchEntries = 500,
)
logCollector.start(bgScope)
attachLogCollector(logCollector)
pw.binom.viewmate.core.logCollector = logCollector
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
// Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте.
// Модель если отсутствует докачивается фоновой корутиной стта (idempotent, по Range). Куда
// уходит готовый текст (onResult) регистрирует владелец — слой записи (ListeningLayout.onEnter);
// приложение результат к mainActivity не шьёт.
stt = GlassesStt(filesDir, bgScope)
stt.ensure()
// Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager,
// navStack — лишь его зеркало. Начальная проекция (корень = Film).
layoutManager.onChange = { layouts -> navStack.value = layouts.map { it.toFrame() } }
navStack.value = layoutManager.layersBottomUp().map { it.toFrame() }
log("app", "layout-менеджер: корень=${layoutManager.current.toFrame()}, navStack=[${navStack.value}]")
} }
companion object { companion object {
@@ -99,7 +99,8 @@ internal class GlassesStateStore(private val file: File) {
/** /**
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager). * Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part * Каталог: filesDir/viewmate/{itemId}/video.mkv — на очки уходит только видео,
* звук (audio-N.ogg) не скачивается, он лежит на телефоне. Скачивание пишется в .part
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send]. * и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
*/ */
class GlassesDownloadManager( class GlassesDownloadManager(
@@ -0,0 +1,20 @@
package pw.binom.viewmate.glasses
import pw.binom.viewmate.core.log.LogBatch
import pw.binom.viewmate.core.log.LogSink
import pw.binom.viewmate.core.protocol.LogBatchMsg
/**
* LogSink коллектора очков: пачка уходит на телефон по связи очков↔телефон
* ([LogBatchMsg]). Связи нет → false: пачка прихраняется в споуле очков
* (filesDir) и ретраится после восстановления связи.
*/
class GlassesLinkLogSink(
private val connection: HostConnection,
) : LogSink {
override suspend fun send(batch: LogBatch): Boolean {
if (!connection.connected.value) return false
connection.sendToHost(LogBatchMsg(entries = batch.entries))
return true
}
}
@@ -12,13 +12,19 @@ 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.AudioSyncInfo
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 +37,19 @@ 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.SttDone
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 +62,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,51 +90,97 @@ 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,
itemId = snapshot.itemId, itemId = snapshot.itemId,
audioIndex = snapshot.audioIndex, audioIndex = snapshot.audioIndex,
sentTs = System.currentTimeMillis(),
durationMs = snapshot.durationMs,
), ),
) )
} }
} }
} }
/** Развилка по [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, durationMs) =
withContext(Dispatchers.Main) { movieController.playbackPosition() } withContext(Dispatchers.Main) { movieController.playbackPosition() }
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex)) sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis(), durationMs = durationMs))
} }
} }
@@ -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()
@@ -266,28 +337,32 @@ class HostConnection(
downloadManager.handleDeleteFiles(msg.itemId) downloadManager.handleDeleteFiles(msg.itemId)
} }
is AudioSyncInfo -> {
// Индикатор |звук − видео| для статус-панели; active=false (видео не играет) — скрываем.
val prev = GlassesApp.instance.audioSyncDiffMs.value
GlassesApp.instance.audioSyncDiffMs.value = if (msg.active) msg.diffMs else null
// Диагностика: скачок diff (по часам очков) — для корреляции с logcat BT-стека телефона за ту же секунду.
if (msg.active && prev != null && msg.diffMs - prev >= 300) {
log("audio", "diff скачок $prev→${msg.diffMs} мс")
}
}
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)") is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
is ShowText -> { is ShowText -> {
addMessage("ShowText: ${msg.text}") addMessage("ShowText: ${msg.text}")
GlassesApp.instance.assistantText.value = msg.text GlassesApp.instance.assistantEngine.onShowText(msg.text)
} }
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is SttPhrase -> { is ChatHistoryMsg -> {
GlassesApp.instance.sttText.value = msg.full log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
log("stt", "фраза: ${msg.phrase}") GlassesApp.instance.assistantEngine.onChatHistory(
} msg.messages
.filter { it.role == "user" || it.role == "assistant" }
is SttDone -> { .map { ChatEntry(it.role, it.content) },
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}") )
GlassesApp.instance.listening.value = false
}
is SttCancel -> {
log("stt", "отменено")
GlassesApp.instance.listening.value = false
} }
is Shutdown -> { is Shutdown -> {
@@ -297,6 +372,9 @@ class HostConnection(
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo) // страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
android.os.Process.killProcess(android.os.Process.myPid()) android.os.Process.killProcess(android.os.Process.myPid())
} }
// Неподдерживаемое host-сообщение — молча игнор (в т.ч. legacy phone-STT: очки не ждут).
else -> Unit
} }
} }
@@ -306,7 +384,7 @@ class HostConnection(
} }
fun stop() { fun stop() {
client?.close() transport?.close()
discovery.stop() discovery.stop()
scope.cancel() scope.cancel()
} }
@@ -1,6 +1,21 @@
package pw.binom.viewmate.glasses package pw.binom.viewmate.glasses
/** Единый лог очков: простой println (работает и в unit-тестах). */ import pw.binom.viewmate.core.log.BatchingLogCollector
/**
* Единый лог очков: println (видно в юнит-тестах) + лог-коллектор — пачки
* уходят на телефон ([GlassesLinkLogSink]); при обрыве связи пачки
* прихраняются в споуле очков (filesDir) и ретраятся позже.
*/
@Volatile
private var logCollector: BatchingLogCollector? = null
/** Подключить коллектор (GlassesApp.onCreate, после HostConnection). */
fun attachLogCollector(collector: BatchingLogCollector) {
logCollector = collector
}
fun log(tag: String, message: String) { fun log(tag: String, message: String) {
println("[view-mate] $tag $message") println("[view-mate] $tag $message")
logCollector?.record(tag, message)
} }
@@ -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
@@ -49,28 +48,22 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.MediaCommand 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.StopStt
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.status.StatusPanelCompose import pw.binom.viewmate.glasses.status.StatusPanelCompose
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
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.LayoutStackContent
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
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
}
/** /**
* DEBUG-имитация жестов (эмулятор без трекпада): * DEBUG-имитация жестов (эмулятор без трекпада):
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click` * `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
@@ -79,9 +72,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")}")
} }
} }
@@ -92,10 +85,10 @@ class MainActivity : BaseEventActivity() {
when (intent?.action) { when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> { Intent.ACTION_SCREEN_OFF -> {
log("ui", "SCREEN_OFF receiver") log("ui", "SCREEN_OFF receiver")
val was = GlassesApp.instance.movieController.pauseIfPlaying() val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("SCREEN_OFF: pause wasPlaying=$was") logEvent("SCREEN_OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock() GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас (receiver) — пауза") if (was) log("ui", "экран погас (receiver) — пауза")
} }
@@ -104,7 +97,7 @@ class MainActivity : BaseEventActivity() {
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn") logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
log("ui", "экран включён (receiver) — продолжаю") log("ui", "экран включён (receiver) — продолжаю")
} }
@@ -116,26 +109,16 @@ 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)
setContent { setContent {
MaterialTheme(colorScheme = darkColorScheme()) { MaterialTheme(colorScheme = darkColorScheme()) {
Box( // Layout-стек (LIFO): персистентный менеджер (GlassesApp.layoutManager) композитит
modifier = Modifier // все слои снизу вверх (кино → затемнение → чат → слушаю). Триггер рекомпозиции —
.fillMaxSize() // стек менеджера (зеркало navStack, обновляется через ctx.open/close).
.background(Color.Black) LayoutStackContent(manager = GlassesApp.instance.layoutManager)
) {
val controller = GlassesApp.instance.movieController
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) {
GlassesMovieScreen(player = player, title = title)
} else {
ConnectionScreen()
}
}
} }
} }
hideSystemBars() hideSystemBars()
@@ -171,16 +154,16 @@ class MainActivity : BaseEventActivity() {
val state = dm.getDisplay(displayId).state val state = dm.getDisplay(displayId).state
logEvent("display state=$state") logEvent("display state=$state")
if (state == Display.STATE_OFF) { if (state == Display.STATE_OFF) {
val was = GlassesApp.instance.movieController.pauseIfPlaying() val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("display OFF: pause wasPlaying=$was") logEvent("display OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock() GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
} else if (state == Display.STATE_ON) { } else if (state == Display.STATE_ON) {
logEvent("display ON: resume flag=$resumeAfterScreenOn") logEvent("display ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
} }
} }
@@ -188,79 +171,46 @@ 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, эмулятор). Жест деспачится в верхний слой стека через персистентный
log("gesture", "TripleClick проигнорирован: распознавание уже идёт") * [pw.binom.viewmate.glasses.LayoutManager] — каждый слой сам решает, что со своим жестом
return * делать (MoveLayout / AssistantLayout / ListeningLayout).
} */
log("stt", "ассистент: слушаю") private fun onTempleAction(action: TempleAction) {
if (GlassesApp.instance.movieController.pauseIfPlaying()) { val g = action.toGesture() ?: run {
log("stt", "кино на паузе (TripleClick)") log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор")
} return
GlassesApp.instance.listening.value = true }
GlassesApp.instance.sttText.value = "" log("gesture", "жест: ${g}")
GlassesApp.instance.assistantText.value = "" GlassesApp.instance.layoutManager.dispatch(g)
sttMic.start(filesDir) { GlassesApp.instance.listening.value } }
}
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */ // Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]:
private fun handleClick() { // слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout.
if (GlassesApp.instance.listening.value) { // Activity о распознавании не знает: запись самозамкнута в [ListeningLayout] (локальная
log("gesture", "Click → завершить распознавание") // стт), host-STT-события очки не обрабатывают (молча игнор в HostConnection).
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
return
}
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
private fun handleDoubleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
}
return
}
log("gesture", "жест (резерв): DoubleClick")
}
/** /**
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
@@ -287,11 +237,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 (пользователь ушёл
// в лаунчер) режим общения гарантированно закрывается — стек POP-ится до фильма.
if (app.navStack.value.size > 1) {
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
app.assistantEngine.exitMode("onPause — потеря фокуса, выход из режима общения")
}
val was = app.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("onPause: pause wasPlaying=$was") logEvent("onPause: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock() app.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() app.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас — пауза") if (was) log("ui", "экран погас — пауза")
} }
@@ -302,7 +259,7 @@ class MainActivity : BaseEventActivity() {
logEvent("onResume: resume flag=$resumeAfterScreenOn") logEvent("onResume: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
log("ui", "экран включён — продолжаю") log("ui", "экран включён — продолжаю")
} }
@@ -373,67 +330,43 @@ fun ConnectionScreen() {
val containerW = (screenWidthPx * scale).toInt() val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt() val containerH = (screenHeightPx * scale).toInt()
Box( // Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
modifier = Modifier // поверх него. Панель — у низа экрана при любом масштабе/вертикали.
.width(containerW.px) Box(Modifier.fillMaxSize()) {
.height(containerH.px) Box(
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px) modifier = Modifier
.background(Color.Black), .width(containerW.px)
) { .height(containerH.px)
Column( .offset(
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp), x = scaleOffsetX(side, screenWidthPx, scale).px,
verticalArrangement = Arrangement.spacedBy(8.dp), // вертикаль (vertical) — только для видео; здесь контент всегда у верха
)
.background(Color.Black),
) { ) {
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium) Column(
Text(if (connected) "подключено к телефону" else "нет соединения") modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
Text(status, style = MaterialTheme.typography.bodyMedium) verticalArrangement = Arrangement.spacedBy(8.dp),
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(messages) { msg ->
Text(msg, style = MaterialTheme.typography.bodySmall)
}
}
}
StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.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( Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
modifier = Modifier.align(Alignment.Center).padding(16.dp), Text(if (connected) "подключено к телефону" else "нет соединения")
horizontalAlignment = Alignment.CenterHorizontally, Text(status, style = MaterialTheme.typography.bodyMedium)
) { Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
Text( LazyColumn(modifier = Modifier.fillMaxWidth()) {
if (sttText.isBlank()) "слушаю..." else sttText, items(messages) { msg ->
color = Color.White, Text(msg, style = MaterialTheme.typography.bodySmall)
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,
)
} }
} }
} }
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
} }
StatusPanelCompose(
modifier = Modifier
.align(Alignment.BottomCenter)
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
.padding(bottom = 10.dp),
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
)
} }
} }
} }
@@ -70,6 +70,8 @@ internal data class PlaybackSnapshot(
val playing: Boolean, val playing: Boolean,
val itemId: String?, val itemId: String?,
val audioIndex: Int, val audioIndex: Int,
/** Длительность видео, мс; -1 = неизвестно (плеера нет / ещё не prepared). */
val durationMs: Long = -1L,
) )
/** /**
@@ -239,10 +241,10 @@ class MovieController(private val context: Context) {
} }
} }
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */ /** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex, длительность). Только с главного потока. */
internal fun playbackPosition(): PlaybackSnapshot { internal fun playbackPosition(): PlaybackSnapshot {
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex) val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex)
return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex) return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex, player.duration)
} }
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */ /** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
@@ -0,0 +1,253 @@
package pw.binom.viewmate.glasses.gestures
import com.rayneo.arsdk.android.touch.TempleAction
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.glasses.stt.SttModelStatus
/**
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
*
* Навигация — ЛIFO-стек [NavFrame]: корень [NavFrame.Film] (двойным кликом не вытолкнуть),
* сверху [NavFrame.Assistant] (чат), сверху [NavFrame.Listening] (микрофон, чат на фоне).
* Двойной клик — POP верхнего кадра. Режим выводится из вершины стека + контента кадра
* ассистента:
*
* @NONE — кино (вершина Film): ассистент не вызван.
* @LISTENING — вершина Listening: микрофон открыт, чат виден на фоне.
* @THINKING — вершина Assistant, фраза отправлена, ждём ответ (ShowText).
* @ANSWER — вершина Assistant, ответ виден (assistantText непуст).
* @IDLE — вершина Assistant без «думаю»/ответа: микрофон закрыт, чат на месте.
*/
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, IDLE, }
/** Кадр стека навигации очков. Снизу вверх: [Film] (корень) → [Assistant] → [Listening]. */
enum class NavFrame { Film, Assistant, Listening }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState(
/** Стек навигации, снизу вверх. Внизу всегда [NavFrame.Film] (корень). */
val stack: List<NavFrame> = listOf(NavFrame.Film),
/** «Думаю»: фраза отправлена, ждём ответ (ShowText). Контент кадра ассистента. */
val thinking: Boolean = false,
/** Живой текст записи (локальное распознавание в [ListeningLayout]). Контент кадра «слушаю». */
val sttText: String = "",
/** Ответ ассистента (ShowText). Контент кадра ассистента. */
val assistantText: String = "",
/** Попытка начать диктовку отклонена: модель не скачалась (микрофон не открыт). */
val sttStartBlocked: Boolean = false,
/** Последний стоп/отправка не поймала голоса — показать «не расслышал». */
val sttNoSpeech: Boolean = false,
) {
/** Вершина стека — текущий кадр. */
val top: NavFrame get() = stack.last()
/** Микрофон открыт: вершина — [NavFrame.Listening]. */
val listening: Boolean get() = top == NavFrame.Listening
/** В кадре ассистента (idle/думаю/ответ/слушаю): вершина Assistant или Listening. */
val inAssistant: Boolean get() = top == NavFrame.Assistant || top == NavFrame.Listening
}
/**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; вершина [NavFrame.Listening] всегда побеждает (микрофон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*
* Стек: фильм (корень) → ассистент → слушаю. «Ассистент» без «слушаю»/ответа/«думаю»
* (вершина [NavFrame.Assistant]) — idle-кадр [IDLE]: микрофон закрыт, чат не прерван.
*/
fun assistantMode(state: AssistantState): AssistantMode = when {
state.top == NavFrame.Listening -> AssistantMode.LISTENING
state.top == NavFrame.Assistant -> when {
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
else -> AssistantMode.NONE
}
/**
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
* Матрица (docs/gestures-and-modes.md):
*
* | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------|
* | Кино (корень) | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | стоп → idle | игнор | игнор |
* | Ассистент (idle) | открыть запись | выход | запись | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/
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
/**
* Двойной в «слушаю» (стоп, POP верхнего кадра): закрыть микрофон, ОСТАТЬСЯ в ассистенте
* (стек: фильм → ассистент, без «слушаю» сверху) — см. [AssistantState.stopListeningFrame].
* Выхода нет: клик открывает запись снова, двойной — выход из чата. Сторона очков:
* StopStt(cancel=true).
*/
data object StopListening : GestureDecision
/** Клик/тройной в idle-кадре ассистента: снова открыть микрофон (вниз по стеку — «слушаю»). */
data object BeginListening : GestureDecision
/** Двойной в «думаю»/«ответ виден»/idle: выход из режима — закрыть оверлей, фильм на паузе. */
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]).
*/
fun gestureDecision(
action: TempleAction,
state: AssistantState,
seekStepMs: Long = 30_000,
): 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
}
// «Слушаю» (вершина — Listening). Модель не скачалась (стtStartBlocked): микрофон не открыт,
// кадр на месте — Click = повторить попытку, DoubleClick = выход. Иначе: Click — отправить,
// DoubleClick — стоп (POP «слушаю» → idle).
AssistantMode.LISTENING -> when {
state.sttStartBlocked -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
else -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase
// Стоп: POP «слушаю» → кадр ассистента (idle), чат не прерван.
is TempleAction.DoubleClick -> GestureDecision.StopListening
else -> GestureDecision.Ignore
}
}
// Idle-кадр ассистента (стек: фильм → ассистент, без «слушаю»): микрофон закрыт.
AssistantMode.IDLE -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.TripleClick -> GestureDecision.BeginListening
// Выход из чата: POP «ассистент» → корень (кино).
is TempleAction.DoubleClick -> GestureDecision.ExitMode
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
else -> GestureDecision.Ignore
}
}
/**
* Тройной клик в фильме: на стек кладём кадр ассистента (чат) и сразу кадр «слушаю»
* (микрофон) — стек [Film, Assistant, Listening]. Чат виден на фоне. Чистая функция.
*/
fun AssistantState.enterAssistant(): AssistantState =
copy(
stack = stack + NavFrame.Assistant + NavFrame.Listening,
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Клик в idle/«думаю»/«ответ»: кладём кадр «слушаю» поверх кадра ассистента. Чистая функция. */
fun AssistantState.beginListeningFrame(): AssistantState =
copy(stack = stack + NavFrame.Listening, thinking = false, sttText = "", sttNoSpeech = false)
/**
* Стоп-записи ([GestureDecision.StopListening], двойной в «слушаю» / пустой клик): POP кадра
* «слушаю» — остаёмся в кадре ассистента (idle), чат НЕ прерван, микрофон закрыт. [noSpeech] —
* показать «не расслышал». Возобновить запись — Click/Triple ([GestureDecision.BeginListening]);
* выйти — DoubleClick ([GestureDecision.ExitMode]). Чистая функция — unit-тесты без Android.
*/
fun AssistantState.stopListeningFrame(noSpeech: Boolean): AssistantState =
copy(stack = stack.dropLast(1), thinking = false, sttText = "", sttNoSpeech = noSpeech)
/**
* Выход из чата ([GestureDecision.ExitMode], двойной в кадре ассистента / таймаут тишины):
* POP кадра ассистента до корня [NavFrame.Film] (фильм). Сброс контента. Чистая функция.
*/
fun AssistantState.exitToFilm(): AssistantState =
copy(
stack = listOf(NavFrame.Film),
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Оверлей общения виден, пока в кадре ассистента (idle/думаю/ответ/слушаю) или блок модели. */
fun overlayVisible(state: AssistantState): Boolean =
state.inAssistant || state.sttStartBlocked
/**
* Заголовок оверлея (над ответом). null — только ответ.
* - «модель не скачалась» — попытка начать диктовку отклонена (модель не READY),
* микрофон не открыт; ниже живая строка прогресса ([modelStatusLine]);
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт;
* - «не расслышал» — стоп/отправка не поймала голоса (пустой клик);
* - кадр ассистента (idle/думаю без ответа/ответ виден) — подсказка жестов
* (клик — говорить, двойной — выход); чат прокручивается под заголовком.
*/
fun overlayHeader(state: AssistantState): String? = when {
state.sttStartBlocked -> "модель не скачалась"
state.thinking && state.assistantText.isBlank() -> "думаю..."
state.top == NavFrame.Listening -> state.sttText.ifBlank { "слушаю..." }
state.sttNoSpeech -> "не расслышал — клик, чтобы повторить"
state.top == NavFrame.Assistant -> "клик — говорить · двойной — выход"
else -> state.sttText.takeIf { it.isNotBlank() }
}
/**
* Строка статуса модели Qwen3 для оверлея (показывается, когда модель не READY и
* микрофон открыт — распознавание гарантированно пустое): null — модель готова
* (ничего не показывать); DOWNLOADING — «качается N%»; MISSING — не скачана;
* FAILED — не скачалась (пойдут авто-повторы). Чистая функция — юнит-тесты без Android.
*/
fun modelStatusLine(status: SttModelStatus): String? = when (status.phase) {
SttModelStatus.Phase.READY -> null
SttModelStatus.Phase.DOWNLOADING -> "модель качается: ${status.percent}%"
SttModelStatus.Phase.MISSING -> "модель не скачана — распознавание недоступно"
SttModelStatus.Phase.FAILED -> "модель не скачалась — повторю автоматически"
}
/**
* Позиция скролла ответа после применения дельты [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)
@@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -18,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import pw.binom.viewmate.glasses.GlassesApp
/** /**
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content, * Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
@@ -36,12 +38,20 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
val d = data ?: return val d = data ?: return
val baseStyle = MaterialTheme.typography.bodyMedium val baseStyle = MaterialTheme.typography.bodyMedium
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale) val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
// + свежее значение от телефона); без видео — ничего не выводить.
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
val movieActive = GlassesApp.instance.rootLayer.controller.movieActive.collectAsState()
Row( Row(
modifier = modifier modifier = modifier
.background(Color(0x99000000)) // полупрозрачный чёрный .background(Color(0x99000000)) // полупрозрачный чёрный
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale), .padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (movieActive.value && diffMs.value != null) {
Text("${diffMs.value} мс", color = Color(0xFF999999), style = style)
Spacer(Modifier.width(6.dp * scale))
}
Text(d.time, color = Color.White, style = style) Text(d.time, color = Color.White, style = style)
Spacer(Modifier.width(6.dp * scale)) Spacer(Modifier.width(6.dp * scale))
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика. // Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
@@ -0,0 +1,43 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import pw.binom.asr.qwen3.Qwen3Config
import pw.binom.asr.qwen3.createQwen3Sherpa
import pw.binom.viewmate.glasses.log
/**
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
* [SttModelDownloader]).
*/
class GlassesAsr(
modelsDir: File,
numThreads: Int = 4,
) {
private val modelsDir = modelsDir
private val numThreads = numThreads
/**
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
*/
private val recognizer by lazy {
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
createQwen3Sherpa(
Qwen3Config(
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
numThreads = numThreads,
),
)
}
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
runCatching { recognizer.recognize(samples, sampleRate) }
.onFailure { log("stt", "Qwen3 recognizer упал: ${it.javaClass.simpleName}: ${it.message}") }
.getOrDefault("")
}
@@ -0,0 +1,31 @@
package pw.binom.viewmate.glasses.stt
/**
* Абстракция механизма распознавания очков: готов ли механизм + распознать фразу.
*
* [GlassesSttSession] работает с этим интерфейсом, а не с конкретным движком — так
* дальше за ним можно подключить другой механизм (например, удалённое распознавание
* на телефоне) без изменения сессии.
*/
interface GlassesRecognizer {
/** Механизм готов (модель докачана / нативка загрузится). false — не открывать микрофон. */
fun isReady(): Boolean
/** Распознать фразу (FloatArray, 16 кГц mono). Пустая строка — тишина / не готово. */
fun recognize(phrase: FloatArray): String
}
/**
* Единственная реализация: локальный механизм распознавания очков — Qwen3-ASR
* ([GlassesAsr]); готовность берёт из статуса скачанной модели ([SttModelDownloader]).
* Телефон в распознавании не участвует.
*/
class LocalGlassesRecognizer(
private val asr: GlassesAsr,
private val model: SttModelDownloader,
) : GlassesRecognizer {
override fun isReady(): Boolean = model.status.value.ready
override fun recognize(phrase: FloatArray): String = asr.recognize(phrase)
}
@@ -0,0 +1,154 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.sqrt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.stt.s16leToFloat
import pw.binom.viewmate.core.stt.splitPhrases
import pw.binom.viewmate.core.stt.trimEdges
import pw.binom.viewmate.glasses.log
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
interface GlassesMic {
fun start(filesDir: File, shouldRun: () -> Boolean)
fun stop()
}
/**
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
* локально (через [recognizer]) и возвращаем готовый текст — телефон больше
* не участвует в распознавании. UX остаётся «говори → клик → текст».
*
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
* пусто после 30s-лимита (timeout) — автозавершение.
*/
class GlassesSttSession(
private val filesDir: File,
private val scope: CoroutineScope,
private val recognizer: GlassesRecognizer,
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
) {
/**
* Обработчик готового текста ([finish] → [onResult] на главном потоке). Сессия лишь
* *порождает* результат — куда он уходит, решает владелец: слой записи
* (ui.layers.ListeningLayout) регистрирует в onEnter, сбрасывает в onExit
* (`onResult = null` — слоя нет → результата некому принять).
*/
var onResult: ((text: String, auto: Boolean) -> Unit)? = null
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
private val sessionLimitMs = 30_000L
private var mic: GlassesMic? = null
/**
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
*/
private val pcmLock = ReentrantLock()
private var pcm = ByteArray(0)
private var watchdog: Job? = null
@Volatile
private var running = false
val isRunning: Boolean get() = running
/**
* Начать слушать: чистый буфер + микрофон + таймер автозавершения.
* Гейт в момент ПОПЫТКИ начать сбор: если механизм ещё не готов ([GlassesRecognizer.isReady]=false),
* микрофон НЕ открываем и PCM не накапливаем — сбор не начинается, распознавание
* гарантированно не будет пустым «потом». Ошибку показывает UI (sttStartBlocked).
*/
fun start() {
if (running) return
if (!recognizer.isReady()) {
log("stt", "STT-сессия очков: механизм не готов — микрофон не открываю, сбор не начинаю")
return
}
running = true
pcmLock.withLock { pcm = ByteArray(0) }
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
mic = m
m.start(filesDir) { running }
watchdog = scope.launch {
delay(sessionLimitMs)
finish(auto = true)
}
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
}
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
fun send() = finish(auto = false)
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
fun cancel() {
stopMic()
watchdog?.cancel()
pcmLock.withLock { pcm = ByteArray(0) }
log("stt", "STT-сессия очков: отмена")
}
/** Полный стоп (выход из режима ассистента). */
fun stop() {
running = false
stopMic()
watchdog?.cancel()
pcmLock.withLock { pcm = ByteArray(0) }
log("stt", "STT-сессия очков: закрыта")
}
private fun finish(auto: Boolean) {
if (!running) return
running = false
stopMic()
watchdog?.cancel()
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
scope.launch(Dispatchers.Default) {
val text = if (audio.isNotEmpty() && recognizer.isReady()) {
val floats = s16leToFloat(audio)
var peak = 0f
for (f in floats) { val a = if (f < 0f) -f else f; if (a > peak) peak = a }
val win = 320
var w005 = 0; var w001 = 0
var i = 0
while (i + win <= floats.size) {
var sum = 0.0
for (k in i until i + win) sum += floats[k] * floats[k].toDouble()
val rms = sqrt(sum / win).toFloat()
if (rms >= 0.005f) w005++
if (rms >= 0.01f) w001++
i += win
}
val trimmed = trimEdges(floats)
val phrases = splitPhrases(trimmed)
log("stt", "diag pcm=${audio.size / 32}мс peak=${peak} окно[≥.005]=${w005} [≥.01]=${w001} trim=${trimmed.size / 32000}с фраз=${phrases.size}")
val joined = phrases.mapIndexed { n, p ->
val r = recognizer.recognize(p)
log("stt", "diag фраза${n + 1} ${p.size * 1000 / 16000}мс → '${r.take(40)}'")
r
}.joinToString(" ")
if (joined.isEmpty()) log("stt", "diag: результат пустой")
joined
} else {
log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено")
""
}
withContext(Dispatchers.Main) { onResult?.invoke(text, auto) }
}
}
private fun stopMic() {
mic?.stop()
mic = null
}
}
@@ -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,65 +10,112 @@ 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,
private val onChunk: suspend (ByteArray) -> Unit, private val onChunk: suspend (ByteArray) -> Unit,
) { ) : GlassesMic {
private var job: Job? = null private var job: Job? = null
fun start(filesDir: File, shouldRun: () -> Boolean) { /** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */
private var dump: WavWriter? = null
override 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", "микрофон остановлен")
} }
} }
} }
fun stop() { override 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,235 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.security.MessageDigest
import kotlin.concurrent.Volatile
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import pw.binom.viewmate.glasses.log
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
/**
* Модель Qwen3-ASR 0.6B int8 для очек: 4 компонента в [filesDir]/models
* (та же иерархия, что на телефоне — `PhoneApp.ensureStt`: conv/encoder/decoder +
* tokenizer/). Скачиваются с зеркала [SttModel.BASE_URL] при отсутствии локально:
* докачка с места обрыва (Range), проверка SHA-256, сквозной прогресс.
*
* Зеркало — внутренний `static.binom.pw/models/qwen3_06/` (обычная иерархия
* корневых .onnx + подкаталог tokenizer/), URL зашит константой.
*/
object SttModel {
/** Корень зеркала модели: обычные имена файлов + подкаталог tokenizer/. */
const val BASE_URL = "https://static.binom.pw/models/qwen3_06"
/** Иерархия модели + размер и SHA-256 каждого файла (контроль целостности). */
internal val FILES: List<ModelFile> = listOf(
ModelFile("conv_frontend.onnx", 44_148_281L, "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"),
ModelFile("encoder.int8.onnx", 182_491_662L, "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"),
ModelFile("decoder.int8.onnx", 755_914_231L, "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"),
ModelFile("tokenizer/merges.txt", 1_671_853L, "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"),
ModelFile("tokenizer/vocab.json", 2_776_833L, "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"),
ModelFile("tokenizer/tokenizer_config.json", 12_487L, "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"),
)
}
/** Один файл модели: относительный путь (в models/), размер и SHA-256. */
internal data class ModelFile(
val relPath: String,
val sizeBytes: Long,
val sha256Hex: String,
)
/** Быстрый «довольны ли файлом»: есть + размер совпадает (SHA проверяем на докатке). */
internal fun isSatisfied(modelsDir: File, f: ModelFile): Boolean {
val final = File(modelsDir, f.relPath)
return final.isFile && final.length() == f.sizeBytes
}
/** Какие файлы [files] нужны (отсутствуют или битые по размеру). */
internal fun whichFilesNeeded(modelsDir: File, files: List<ModelFile>): List<ModelFile> =
files.filter { !isSatisfied(modelsDir, it) }
/** Модель на диске полностью: все файлы [files] на месте с верным размером. */
internal fun isModelComplete(modelsDir: File, files: List<ModelFile> = SttModel.FILES): Boolean =
whichFilesNeeded(modelsDir, files).isEmpty()
/** Все байты всех файлов модели (для сквозного процента). */
internal fun modelTotalBytes(files: List<ModelFile> = SttModel.FILES): Long = files.sumOf { it.sizeBytes }
/** SHA-256 файла (потоком, без загрузки всего в память). */
internal fun sha256Hex(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(1 shl 16)
while (true) {
val n = input.read(buf)
if (n < 0) break
digest.update(buf, 0, n)
}
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
/** Статус модели: что происходит со скачиванием (для индикатора в UI очков). */
data class SttModelStatus(
val phase: Phase,
val percent: Int = 0,
val detail: String = "",
) {
enum class Phase { MISSING, DOWNLOADING, READY, FAILED }
val ready: Boolean get() = phase == Phase.READY
}
/**
* Скачивание модели Qwen3-ASR в [modelsDir] (filesDir/models). Прогресс — в [status].
* Идиомпотентно: [ensure] не делает ничего, если модель уже на месте; иначе докачивает
* недостающие/битые файлы с Range и проверяет SHA-256.
*/
class SttModelDownloader(
private val modelsDir: File,
private val scope: CoroutineScope,
private val okHttp: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // большие файлы, паузы в потоке
.build(),
) {
private val _status = MutableStateFlow(SttModelStatus(SttModelStatus.Phase.MISSING))
val status: StateFlow<SttModelStatus> = _status.asStateFlow()
@Volatile
private var downloadJob: Job? = null
/** Таймер авто-повтора после ошибки (сеть могла появиться позже — см. [scheduleRetry]). */
@Volatile
private var retryJob: Job? = null
init {
refresh()
}
companion object {
/** Пауза до авто-повтора скачивания после FAILED: сеть могла появиться позже. */
private const val RETRY_DELAY_MS = 15_000L
}
/** Перечитать с диска: READY (готово) или MISSING (не хватает). */
fun refresh() {
_status.value = if (isModelComplete(modelsDir)) {
SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
} else {
SttModelStatus(SttModelStatus.Phase.MISSING, overallPercent(), "нет локально — скачаю при старте")
}
}
/** Скачать недостающее (idempotent; повторный вызов пока идёт — игнор). */
fun ensure() {
if (isModelComplete(modelsDir)) {
refresh()
return
}
if (downloadJob?.isActive == true) return
downloadJob = scope.launch {
try {
val total = modelTotalBytes()
val needed = whichFilesNeeded(modelsDir, SttModel.FILES)
var done = total - needed.sumOf { it.sizeBytes } // байты уже готовых файлов
needed.forEach { f ->
currentCoroutineContext().ensureActive()
downloadOne(f) { fileDone ->
val overall = ((done + fileDone) * 100 / total).toInt().coerceIn(0, 100)
_status.value = SttModelStatus(SttModelStatus.Phase.DOWNLOADING, overall, "${f.relPath}: $overall%")
}
done += f.sizeBytes
log("stt", "модель: скачан ${f.relPath} (${f.sizeBytes} Б)")
}
_status.value = SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
log("stt", "модель Qwen3-ASR готова в ${modelsDir.absolutePath}")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("stt", "ошибка скачивания модели: ${e.message}")
_status.value = SttModelStatus(SttModelStatus.Phase.FAILED, overallPercent(), "ошибка: ${e.message}")
scheduleRetry()
}
}
}
/**
* Авто-повтор после [SttModelStatus.Phase.FAILED]: сеть могла появиться позже
* (Wi-Fi очков выключен при старте, DNS не резолвится — фиксированные случаи).
* Один таймер: новый провал перезапускает его; при готовности/идущем скачивании
* [ensure] — no-op, так что повторы сами завершаются.
*/
private fun scheduleRetry() {
retryJob?.cancel()
retryJob = scope.launch {
delay(RETRY_DELAY_MS)
log("stt", "модель: авто-повтор скачивания (ошибка могла быть сессионной — сеть)")
ensure()
}
}
/** Скачать один файл: Range-докачка в .part, SHA-256, rename в финальный. onProgress — байты файла. */
private suspend fun downloadOne(f: ModelFile, onProgress: (Long) -> Unit) {
val parent = File(modelsDir, f.relPath).parentFile
parent?.mkdirs()
val final = File(modelsDir, f.relPath)
val part = File(modelsDir, "${f.relPath}.part")
val existing = if (part.exists()) part.length() else 0L
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
if (existing > 0) builder.header("Range", "bytes=$existing-")
okHttp.newCall(builder.build()).execute().use { response ->
val code = response.code
if (code != 200 && code != 206) error("HTTP $code для ${f.relPath}")
val body = response.body
val resumed = code == 206 && existing > 0
val input = body.byteStream()
FileOutputStream(part, resumed).use { out ->
var done = if (resumed) existing else 0L
onProgress(done)
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
currentCoroutineContext().ensureActive()
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
done += read
onProgress(done)
}
}
if (sha256Hex(part) != f.sha256Hex) {
part.delete()
error("SHA-256 не совпал для ${f.relPath}")
}
if (final.exists()) final.delete()
if (!part.renameTo(final)) {
part.copyTo(final, overwrite = true)
part.delete()
}
}
}
/** Сквозной процент: сколько всех байтов модели уже на диске (по размеру). */
private fun overallPercent(): Int {
val total = modelTotalBytes()
if (total <= 0) return 0
val have = SttModel.FILES.filter { isSatisfied(modelsDir, it) }.sumOf { it.sizeBytes }
return (have * 100 / total).toInt().coerceIn(0, 100)
}
}
@@ -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,241 @@
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.modelStatusLine
import pw.binom.viewmate.glasses.gestures.overlayVisible
import pw.binom.viewmate.glasses.gestures.scrollOffset
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.ListeningLayout
/**
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
* каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
* «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
* в том же scale-контейнере, что у экранов. Режим общения явный
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
* Состояния: «слушаю»/«думаю...» — заголовок,
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
* [GlassesApp.answerScrollDelta]).
*/
@Composable
fun AssistantOverlay(engine: AssistantEngine) {
val app = GlassesApp.instance
// Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine].
val navStack by app.navStack.collectAsState()
val thinking by engine.thinking.collectAsState()
// Живой текст распознавания владеет слой записи ([ListeningLayout] — единственный, кто в курсе
// микрофона): читаю из верхнего слоя, не из движка. В локальном режиме он vestigial (заполняется
// на результат и скрывается сразу за POP-ом слоя), поэтому реактивность не критична — значение
// перечитывается на каждую рекомпозицию (нав-стек + статусы ассистента выше).
val sttText = (app.layoutManager.current as? ListeningLayout)?.sttText?.value.orEmpty()
val assistantText by engine.assistantText.collectAsState()
val sttNoSpeech by engine.sttNoSpeech.collectAsState()
val chatHistory by engine.chatHistory.collectAsState()
// «Модель не скачалась» выводится из состояния догрузчика (отдельного логического флага в
// движке нет): распознавание локально, поэтому модель не READY = распознавание не готово —
// объясняем что происходит (качается / не скачалась).
val modelStatus by app.stt.status.collectAsState()
val state = AssistantState(
stack = navStack,
thinking = thinking,
sttText = sttText,
assistantText = assistantText,
sttStartBlocked = !modelStatus.ready,
sttNoSpeech = sttNoSpeech,
)
if (!overlayVisible(state)) return
// Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ модель не READY («не скачалась»).
val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null
// Скролл и эффекты живут ВЫШЕ 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) общий в [engine]. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) {
engine.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
engine.answerScrollDelta.value = 0f
if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150))
}
}
}
overlayBox(app, state, chatHistory, scrollState, modelLine)
}
/**
* Поглазник: затемняющий слой накрывает ВСЁ поле глаза (100% viewport)
* непрозрачным чёрным — поверх SurfaceView-видео и любого положения регулятора
* «Высота» (TZ-ui-four п.4: 72%-полупрозрачный слой поверх контейнера видео
* оставался «проглядывающим» — видео светилось/не затемнялось). Контент
* (текст/редактирование) остаётся в том же scale/offset-контейнере, что у экранов.
*/
@Composable
private fun overlayBox(
app: GlassesApp,
state: AssistantState,
chatHistory: List<ChatEntry>,
scrollState: ScrollState,
modelLine: String?,
) {
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
.fillMaxSize()
// Полупрозрачный: затемнённый фильм (DimScrim снизу) просвечивает позади чата —
// «чат виден на фоне». Чат сам по себе тёмный, читаемость не падает.
.background(Color.Black.copy(alpha = 0.88f)),
) {
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
// вертикаль (vertical) — только для видео; оверлей всегда у верха
),
) {
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
if (state.listening && !state.sttStartBlocked) {
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,
modelLine: String?,
) {
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,
)
}
// Строка статуса модели (только «слушаю» + модель не READY) — объясняет, почему
// распознавание сейчас не работает и что происходит (качается / не скачалась).
if (!modelLine.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = modelLine,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
}
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,34 +104,47 @@ fun GlassesMovieScreen(
maxHeight = containerH - 25, maxHeight = containerH - 25,
) )
Box( // Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
modifier = Modifier // поверх него. Панель — у низа экрана при любом масштабе/вертикали.
.width(containerW.px) Box(Modifier.fillMaxSize()) {
.height(containerH.px) Box(
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px), modifier = Modifier
) { .width(containerW.px)
Row( .height(containerH.px)
modifier = Modifier.fillMaxWidth().background(Color.Black), .offset(
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start, x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
),
) { ) {
if (SCALE_DEBUG_SQUARE) { Row(
// Тестовый режим: большой красный квадрат вместо видео — быстрая modifier = Modifier.fillMaxWidth().background(Color.Black),
// проверка масштаба/позиций скрином без запуска видео. // Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
Box(Modifier.fillMaxSize().background(Color.Red)) // Row-центрирование съедало вертикаль — картинка оставалась «по центру».
} else { verticalAlignment = Alignment.Top,
BinocularVideoPlayer( horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
player = player, ) {
renderer = renderer, if (SCALE_DEBUG_SQUARE) {
modifier = Modifier.size(playerWidth.px, playerHeight.px), // Тестовый режим: большой красный квадрат вместо видео — быстрая
) // проверка масштаба/позиций скрином без запуска видео.
Box(Modifier.fillMaxSize().background(Color.Red))
} else {
BinocularVideoPlayer(
player = player,
renderer = renderer,
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
@@ -0,0 +1,42 @@
package pw.binom.viewmate.glasses.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.LayoutManager
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
/**
* Ленточный (LIFO) layout-стек очков: композитинг снизу вверх — [LayoutManager.compositing]
* рендерит ВСЕ слои (кино → затемнение → чат → слушаю), панель статуса у верхнего.
* Стек правды — персистентный [GlassesApp.layoutManager]; [navStack] — лишь его зеркало
* (для триггера рекомпозиции).
*/
@Composable
fun LayoutStackContent(manager: LayoutManager) {
// Триггер рекомпозиции: стек менеджера меняется через ctx.open/close, а LayoutManager.onChange
// зеркалирует его в [GlassesApp.navStack] (StateFlow). compositing() читает сам стек менеджера
// (deque); изменение navStack запускает рекомпозицию.
val nav by GlassesApp.instance.navStack.collectAsState()
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
manager.compositing().forEach { layer ->
layer.layout.drawStage()
// Затемняем фильм, только когда поверх него что-то есть (чтение nav — и триггер).
if (layer.layout is MoveLayout && nav.size > 1) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
)
}
}
manager.current.drawStatusPanel()
}
}
@@ -0,0 +1,134 @@
package pw.binom.viewmate.glasses.ui.layers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import pw.binom.viewmate.core.protocol.SttText
import pw.binom.viewmate.glasses.ChatEntry
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.gestures.NavFrame
/**
* Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные
* flows в [GlassesApp]. Держит ТОЛЬКО диалог ассистента с телефоном ([thinking]/[assistantText]/
* [chatHistory]/[answerScrollDelta]) и его реакции на ввод ([continueTurn]/[stopToIdle]).
*
* Микрофон и распознавание движку НЕ ЗНАЕТ: всё стт живёт в [ListeningLayout] (единственный, кто
* в курсе микрофона и распознавания); для движка ввод — просто «текст пришёл» ([continueTurn]) или
* «ввод отменён» ([stopToIdle]). Чат-оверлей читает стейт тоже отсюда.
*
* Зависимости (протокол, менеджер стека) берёт лениво из [GlassesApp] в методах — сам движок
* строится без побочных эффектов (его стейт — лишь flows), порядок инициализации полей не критичен.
*/
class AssistantEngine(private val app: GlassesApp) {
// ── Стейт диалога ассистента (был размазанными полями в GlassesApp) ───────────────
/** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */
val thinking = MutableStateFlow(false)
/** Текст ответа ассистента (ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/**
* У ассистента «не расслышал»: последний ввод отменили без голоса — оверлей показывает
* «не расслышал, тапни ещё раз» (idle-hint). Это стейт *отображения* ассистента (факт
* отмены несёт [stopToIdle]), не стт-факт — распознанным текстом движок не владеет.
*/
val sttNoSpeech = MutableStateFlow(false)
/** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */
val answerScrollDelta = MutableStateFlow(0f)
/** Скоуп корутин, которые запускают вызывающие слои (явное ожидание результата записи). */
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// ── Под-состояние кадра ассистента (top == Assistant) ─────────────────────────────
/** idle / thinking / answer — по стейту (top кадра ассистента предполагается активным). */
fun mode(): AssistantMode = when {
assistantText.value.isNotBlank() -> AssistantMode.ANSWER
thinking.value -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
// ── Эффекты (перенесены из GlassesGestureEffects) ────────────────────────────────
/**
* Кадр ассистента открыт ([AssistantLayout.onEnter]) — чистый старт сессии: стираем прошлый
* ответ / «не расслышал» / дельту скролла. Паузу фильма и синх позиций делает ВЛАДЕЛЕЦ (слой
* кино), а не движок: здесь чистится только *свой* стейт.
*/
fun beginSession() {
sttNoSpeech.value = false
assistantText.value = ""
answerScrollDelta.value = 0f
}
/** Логика «думает ассистент или нет»: сотереть показанный ответ и дельту скролла перед новой фразой. */
fun clearAnswer() {
assistantText.value = ""
answerScrollDelta.value = 0f
}
/**
* Ввод принят ([ListeningLayout.onSuccess] → вызывающий [AssistantLayout]): жду ответ
* ([thinking]). Слой записи сам себя POP-нул (ctx.close), его onExit закрыл микрофон —
* движок микрофоном не владеет, стек здесь не трогаем. «Продолжить ход» диалога с
* телефоном = отправить распознанный ввод на LLM ([SttText]) и ожидать ответ.
*/
suspend fun continueTurn(text: String) {
log("stt", "ассистент: фраза «${text.take(40)}» → на телефон (LLM), жду ответ")
app.hostConnection.sendToHost(SttText(text))
thinking.value = true
}
/**
* Ввод отменён ([ListeningLayout.onCancel] → вызывающий [AssistantLayout]): стоп, остаюсь в
* ассистенте (idle). [sttNoSpeech] — idle-hint «не расслышал» (факт отмены, не стт-текст).
*/
fun stopToIdle(reason: String, noSpeech: Boolean) {
log("stt", reason)
thinking.value = false
sttNoSpeech.value = noSpeech
}
/** Выход из режима в корень: closeToRoot (слои POP-нуты, их onExit сам закроет своё), чистим стейт. */
fun exitMode(reason: String) {
log("stt", reason)
app.layoutManager.closeToRoot()
thinking.value = false
sttNoSpeech.value = false
assistantText.value = ""
answerScrollDelta.value = 0f
}
// ── Host-driven записи стейта (вместо прямого писания flows в GlassesApp) ──────────
/** ShowText: ответ ассистента виден, «думаю» сброшено. */
fun onShowText(text: String) {
thinking.value = false
answerScrollDelta.value = 0f
assistantText.value = text
}
/** ChatHistory: история сессии с телефона (фильтрация wire-типов — в HostConnection). */
fun onChatHistory(messages: List<ChatEntry>) {
chatHistory.value = messages
}
}
/** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */
fun Layout.toFrame(): NavFrame = when (this) {
is MoveLayout -> NavFrame.Film
is AssistantLayout -> NavFrame.Assistant
is ListeningLayout -> NavFrame.Listening
else -> NavFrame.Film
}
@@ -0,0 +1,86 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.runtime.Composable
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.stt.GlassesStt
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import kotlin.coroutines.resume
/**
* Кадр ассистента (чат). Полупрозрачный [AssistantOverlay] — затемнённый фильм просвечивает
* на фоне (фильм остаётся ниже в композите). В под-состоянии «слушаю» оверлей сам показывает
* индикатор записи и текущую фразу — отдельного экрана «слушаю» нет, чат остаётся на фоне.
*
* В кадре остаётся только *его* логика: скролы (свайпы), «думает ассистент или нет» и реакция на
* результат записи. Записью микрофона кадр НЕ занимается: клик *вызывает* слой записи
* ([ListeningLayout]) suspend-функцией [listen] и явно ждёт её окончания (текст / отмена);
* сам микрофон запись открывает/закрывает в своём onEnter/onExit.
*/
class AssistantLayout(
private val ctx: LayoutContext,
private val engine: AssistantEngine,
private val stt: GlassesStt,
) : Layout {
@Composable
override fun drawStage() {
AssistantOverlay(engine)
}
@Composable
override fun drawStatusPanel() = Unit
/** Кадр ассистента открыт: чистый старт сессии — стираем прошлый ответ / «не расслышал» / дельту скролла. */
override fun onEnter() {
engine.beginSession()
}
/**
* Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк);
* логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ).
*/
override fun gesture(g: Gesture) {
when (g) {
Gesture.DoubleClick -> engine.exitMode("DoubleClick → выход из чата (кино остаётся на паузе)")
Gesture.Click -> when (engine.mode()) {
AssistantMode.IDLE -> openListening()
AssistantMode.THINKING, AssistantMode.ANSWER -> {
engine.clearAnswer()
openListening()
}
else -> Unit // NONE/LISTENING при top==Assistant невозможны (тогда топ был бы другим)
}
Gesture.TripleClick -> if (engine.mode() == AssistantMode.IDLE) openListening()
Gesture.SwipeForward, Gesture.SwipeBackward -> Unit // логика скролов: в диалоге не скроллим
}
}
/**
* Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания.
* Возвращает распознанный текст ([onSuccess]) или null ([onCancel], любая отмена) —
* слой записи завершает ожидание этими кэлбэками.
*/
private suspend fun listen() = ListeningLayout.listen(ctx, stt)
/** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */
private fun openListening() {
engine.scope.launch {
val text = listen()
if (text != null) {
engine.continueTurn(text) // распознали фразу → жду ответ (thinking); слой записи сам себя POP-нул
} else {
engine.stopToIdle(
"отмена ввода → остаюсь в ассистенте",
noSpeech = true
) // отмена → idle
}
}
}
}
@@ -0,0 +1,140 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.stt.GlassesStt
import kotlin.coroutines.resume
/**
* Слой записи — ЕДИНСТВЕННЫЙ, кто знает про микрофон и распознавание. Он сам открывает
* микрофон ([stt.session.start] в [onEnter]), закрывает ([onExit]) и сам принимает
* стт-решение: клик распознаёт накопленное ([stt.session.send]), локальный результат
* ([onSttResult]) слой переваривает сам и сообщает вызывающему факт исхода кэлбэками
* [onSuccess]/[onCancel]. Хостовых входов нет — в локальной архитектуре слой самозамкнут.
*
* Для всего остального (кино, чат, ассистент) «текст откуда-то берётся» — никто кроме этого
* слоя не в курсе микрофона и распознавания. Зависимость — единый объект [GlassesStt] (весь
* стек распознавания: скачивание, статус, сессия микрофона), а не весь [GlassesApp];
* исхода двух видов: успех — [onSuccess] с текстом, ЛЮБАЯ отмена (двойной тап, тишина,
* модель не готова) — [onCancel]; выхода из режима слой не делает — это уже эффект вызывающего.
*/
class ListeningLayout(
private val ctx: LayoutContext,
private val stt: GlassesStt,
private val onSuccess: (String) -> Unit,
private val onCancel: () -> Unit,
) : Layout {
companion object {
suspend fun listen(
ctx: LayoutContext,
stt: GlassesStt,
) = suspendCancellableCoroutine { context ->
ctx.open { c ->
context.invokeOnCancellation {
c.close()
}
ListeningLayout(c, stt, onSuccess = { text ->
context.resume(text)
}, onCancel = {
context.resume(null)
})
}
}
}
/** Накопленный распознанный текст — живой стейт слоя (оверлей читает через вершину стека). */
val sttText = MutableStateFlow("")
private val modelReady: Boolean get() = stt.status.value.ready
/** Прозрачный слой: сам ничего не рисует — оверлей ассистента (поверх) показывает индикатор и фразу. */
@Composable
override fun drawStage() = Unit
@Composable
override fun drawStatusPanel() = Unit
/** Слой открыт: открыть микрофон (само гейтится готовностью модели: не ready → старт молчит). */
override fun onEnter() {
sttText.value = ""
// Регистрирую себя обработчиком готового текста: слой сам заводит свой результат,
// сессия лишь порождает текст — куда он идёт, знает только владелец (этот слой).
stt.session.onResult = { text, auto -> onSttResult(text, auto) }
stt.session.start()
log("stt", "слои: вхожу в режим записи, стартую сессию + микрофон")
}
/** Слой закрыт: микрофон закрыть + безопасная отмена ожидания (resume однократен, повтор — no-op). */
override fun onExit() {
stt.session.onResult = null
stt.session.stop()
onCancel()
}
/**
* Жесты слоя: клик — распознать накопленное (модель не ready — повторить попытку старта);
* двойной — отмена (стоп, остаюсь в ассистенте) или выход (модель не скачалась). Остальное — игнор.
*/
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click ->
if (modelReady) {
log("stt", "клик → распознаю накопленное")
stt.session.send()
} else {
log("stt", "клик: модель не готова — повторю старт записи")
stt.session.start()
}
Gesture.DoubleClick ->
if (modelReady) {
log("stt", "двойной → отмена (стоп, остаюсь в ассистенте)")
onCancel()
ctx.close()
} else {
log("stt", "двойной: модель не скачалась → отмена, остаюсь в ассистенте")
onCancel()
ctx.close()
}
Gesture.TripleClick,
Gesture.SwipeForward,
Gesture.SwipeBackward -> Unit
}
}
/**
* Обработчик результата ЛОКАЛЬНОГО распознавания. Слой сам заводит свой результат:
* регистрируется на [stt.session.onResult] в [onEnter], сбрасывает в [onExit] —
* извне (телефон/LLM-путь) [onSttResult] не вызывают. Слой сам решает исход:
* - текст не пуст → успех ([onSuccess]) + POP слоя;
* - пусто + авто (тишина 30с) → отмена ([onCancel]) + POP слоя;
* - пусто + клик (без голоса) → отмена ([onCancel]) + POP слоя (не расслышал).
* Текст дальше «уходит от куда-то» (в LLM на телефон) — слой это не видит.
*/
private fun onSttResult(text: String, auto: Boolean) {
sttText.value = text
when {
text.isNotBlank() -> {
log("stt", "распознал: «$text» → отправляю ассистенту")
onSuccess(text)
ctx.close()
}
else -> {
log(
"stt",
"без голоса (${if (auto) "тишина 30с" else "пустой клик"}) → отмена, остаюсь в ассистенте"
)
onCancel()
ctx.close()
}
}
}
}
@@ -0,0 +1,90 @@
package pw.binom.viewmate.glasses.ui.layers
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.glasses.ConnectionScreen
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.MovieController
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
/**
* Кино — корень стека. Рисуется всегда (нижний слой композита): активный фильм —
* [GlassesMovieScreen], иначе экран подключения [ConnectionScreen]. Поверх него LayoutManager
* кладёт затемнение + под-слои (ассистент/слушание), поэтому фильм виден на фоне чата.
*
* Слой *владеет* своим медиастейтом ([controller]: плеер/позиция/wifi-lock) — он живёт со
* стопом корня, а не с жизненным циклом Activity. Протокол/lifecycle/эффекты дотягиваются до
* движка через слой ([controller]). Слой *сам* отвечает за свои жесты: тройной клик — пауза +
* открыть ассистента, клик — плей/пауза, свайпы — seek. Открытие под-слоёв — через [ctx].
*/
class MoveLayout(
private val ctx: LayoutContext,
context: Context,
) : Layout {
/** Медиадвижок слоя: [MoveLayout] — владелец (корень живёт до конца сессии очков). */
val controller = MovieController(context)
@Composable
override fun drawStage() {
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) GlassesMovieScreen(player = player, title = title)
else ConnectionScreen()
}
@Composable
override fun drawStatusPanel() = Unit
/** Жесты кино (слой активен, когда он вершина стека): каждый жест ведёт себя сам. */
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click -> togglePlayback()
Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore)
Gesture.TripleClick -> callAssistant()
Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000)
Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000)
}
}
/** Click (кино): плей/пауза + отправить позицию на телефон. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
controller.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** Свайп (кино): seek на ±[ms]. */
private fun seekBy(cmd: MediaCommand, ms: Long) {
log("gesture", "жест: свайп → $cmd ${ms}ms")
controller.applyMediaCommand(MediaCommandMsg(cmd, ms)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/**
* Тройной клик (кино): *слое-зависимые* эффекты кино — пауза + синх позиций на телефон — и
* штатно открыть под-слой ассистента ([AssistantLayout]) через [ctx]. Свой стейт сессии кадр
* чистит в onEnter ([AssistantEngine.beginSession]); движок из слоя кино не вызывается.
*/
private fun callAssistant() {
log("gesture", "жест: TripleClick → ассистент (стек: фильм → ассистент)")
val app = GlassesApp.instance
if (controller.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
app.hostConnection.sendPlaybackPositionNow()
ctx.open { c ->
AssistantLayout(c, app.assistantEngine, app.stt)
}
}
}
@@ -0,0 +1,168 @@
package pw.binom.viewmate.glasses
import androidx.compose.runtime.Composable
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* [LayoutManager] — ЛIFO-стек layout-ов очков: корень (двойным кликом не вытолкнуть) → под-слои.
* Проверяем: open/close/closeToRoot, вершина, композитинг (затемнение между слоями), индикатор
* записи, маршрутизацию жеста в верхний слой (и только в него).
*/
class LayoutManagerTest {
/** Тестируемый layout: пустые @Composable тела (в юнит-тестах не компонируются), лог жеста. */
private class FakeLayout(
val id: String,
private val onGesture: (Gesture) -> Unit = {},
) : Layout {
val gestures = mutableListOf<Gesture>()
@Composable
override fun drawStage() {}
@Composable
override fun drawStatusPanel() {}
override fun gesture(g: Gesture) {
gestures += g
onGesture(g)
}
}
private val root = FakeLayout("film")
private val assistant = FakeLayout("assistant")
@Test
fun `root is the initial current`() {
val m = LayoutManager { _ -> root }
assertEquals(1, m.depth)
assertIs<FakeLayout>(m.current)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `open pushes a child on top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
assertEquals(2, m.depth)
assertEquals("assistant", (m.current as FakeLayout).id)
assertEquals(listOf("film", "assistant"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `close pops to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `close on root is a no-op`() {
val m = LayoutManager { _ -> root }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `closeToRoot drops every non-root layer`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
assertEquals(3, m.depth)
m.closeToRoot()
assertEquals(1, m.depth)
assertEquals(listOf("film"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `compositing dims every layer but the top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
val layers = m.compositing()
assertEquals(3, layers.size)
// film → dim, assistant → dim, listening → без затемнения.
assertEquals(listOf("film", "assistant", "listening"), layers.map { (it.layout as FakeLayout).id })
assertEquals(listOf(true, true, false), layers.map { it.dim })
}
@Test
fun `single layer composites without dim`() {
val m = LayoutManager { _ -> root }
val layers = m.compositing()
assertEquals(listOf(false), layers.map { it.dim })
}
@Test
fun `dispatch routes only to the top layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.dispatch(Gesture.Click)
// Вершина (assistant) получила жест, корень — нет.
assertEquals(listOf(Gesture.Click), assistant.gestures)
assertEquals(0, root.gestures.size)
}
@Test
fun `dispatch after close routes back to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
m.dispatch(Gesture.DoubleClick)
assertEquals(0, assistant.gestures.size)
assertEquals(listOf(Gesture.DoubleClick), root.gestures)
}
@Test
fun `recording indicator reflects an active layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
assertFalse(m.recordingIndicator())
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.setRecordingIndicator(false)
assertFalse(m.recordingIndicator())
}
@Test
fun `popping a recording layer turns the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.close()
// assistant (POP) выключил индикатор — корень его не включал.
assertFalse(m.recordingIndicator())
}
@Test
fun `a re-opened layer starts with the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
m.close()
m.open { FakeLayout("assistant2") }
// Новый экземпляр не наследует включение — индикатор выключен, пока сам не включит.
assertFalse(m.recordingIndicator())
}
@Test
fun `child receives the shared context`() {
var received: LayoutContext? = null
val m = LayoutManager { _ -> root }
m.open { ctx -> received = ctx; assistant }
assertSame(m, received)
assertTrue(m.recordingIndicator() == false)
}
private fun assertSame(a: Any?, b: Any?) {
if (a !== b) throw AssertionError("ожидал тот же экземпляр контекста")
}
}
@@ -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,391 @@
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
import pw.binom.viewmate.glasses.stt.SttModelStatus
/**
* Карта жеста (docs/gestures-and-modes.md) — ЛIFO-стек кадров:
* [NavFrame.Film] (корень) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон).
* Двойной клик POP-ает верхний кадр; корень не вытолкнуть. Плюс свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея.
*/
class AssistantGesturesTest {
// Стек-состояния (вершина — текущий кадр). Корень всегда [NavFrame.Film].
private val movie = AssistantState() // [Film]
private val idle = AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant))
private val listening =
AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening))
private val thinking = idle.copy(thinking = true)
private val answer = idle.copy(assistantText = "ответ")
private val blocked = listening.copy(sttStartBlocked = true)
// ————— режим по вершине стека —————
@Test
fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
}
/** Вершина Listening всё побеждает — жесты идут по матрице «слушаю». */
@Test
fun listeningTopWins() {
val s = listening.copy(thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** «Ответ виден»: вершина Assistant, assistantText непуст — даже при thinking. */
@Test
fun answerWinsOverThinking() {
assertEquals(
AssistantMode.ANSWER,
assistantMode(idle.copy(thinking = true, assistantText = "а")),
)
}
@Test
fun thinkingOnlyWhenNoAnswer() {
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
}
@Test
fun idleWhenAssistantTopNoContent() {
assertEquals(AssistantMode.IDLE, assistantMode(idle))
}
// ————— кино (вершина Film) —————
@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)
}
// ————— слушаю (вершина Listening) —————
@Test
fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.StopListening, 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))
}
/** Модель не скачалась: кадр Listening на месте, микрофон закрыт — клик повторить, двойной выход. */
@Test
fun listeningBlockedMatrix() {
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), blocked))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), blocked))
}
// ————— «думаю» / «ответ» (вершина Assistant) —————
@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.Ignore, gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
}
// ————— стек-переходы —————
/** Тройной клик в кино: кладём «ассистент» и сразу «слушаю» — [Film, Assistant, Listening]. */
@Test
fun enterAssistantPushesBothFrames() {
val s = movie.enterAssistant()
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), s.stack)
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** Click в idle/«думаю»/«ответ»: кладём «слушаю» поверх кадра ассистента. */
@Test
fun beginListeningFramePushesListening() {
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), idle.beginListeningFrame().stack)
assertEquals(AssistantMode.LISTENING, assistantMode(answer.beginListeningFrame()))
}
/** DoubleClick в «слушаю» — стоп: POP кадра, остаёмся в ассистенте (idle), чат не прерван. */
@Test
fun stopListeningStaysInAssistant() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.StopListening, decision)
// POP кадра «слушаю» — кадр ассистента (idle), оверлей остаётся, выхода нет.
val after = listening.stopListeningFrame(noSpeech = false)
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant), after.stack)
assertEquals(AssistantMode.IDLE, assistantMode(after))
assertTrue(overlayVisible(after))
assertFalse(after.listening)
// Idle: Click/Triple — возобновить запись; DoubleClick — POP ассистента (выход к фильму).
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), after))
assertEquals(GestureDecision.BeginListening, gestureDecision(TripleClick(), after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
}
/** Стоп первой записи (пустая история): оверлей НЕ закрывается — остаёмся в idle-кадре. */
@Test
fun stopFirstRecordingKeepsOverlay() {
val stopped = listening.copy(sttNoSpeech = true).stopListeningFrame(noSpeech = true)
assertTrue(stopped.assistantText.isBlank())
assertFalse(stopped.listening)
// Оверлей виден (idle-кадр), хотя ни фразы, ни ответа нет.
assertTrue(overlayVisible(stopped))
// И только явный выход (POP до фильма) прячет оверлей.
val exited = stopped.exitToFilm()
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** Стоп сбрасывает накопленный текст текущей (останавливаемой) записи. */
@Test
fun stopListeningClearsCancelledText() {
val after = listening.copy(sttText = "слова до отмены").stopListeningFrame(noSpeech = false)
assertEquals("", after.sttText)
assertFalse(after.listening)
}
/** Выход: POP всё до корня (фильм), сброс контента. */
@Test
fun exitToFilmPopsToRoot() {
val exited =
listening.copy(sttText = "ф", thinking = true, assistantText = "а", sttNoSpeech = true)
.exitToFilm()
assertEquals(listOf(NavFrame.Film), exited.stack)
assertFalse(overlayVisible(exited))
assertTrue(exited.assistantText.isBlank())
assertFalse(exited.thinking)
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@Test
fun sessionTransitions() {
var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.enterAssistant()
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.stopListeningFrame(noSpeech = false).copy(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.exitToFilm()
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** Корень «фильм»: двойным кликом попу нельзя; вход в ассистент — Triple. */
@Test
fun filmRootCannotBePopped() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
}
// ————— оверлей —————
@Test
fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: стек [Film], оверлей невидим.
assertFalse(overlayVisible(movie))
}
@Test
fun overlayVisibleInAssistantFrames() {
assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer))
assertTrue(overlayVisible(idle))
}
@Test
fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking))
// После отправки фразы: вершина ещё Listening + thinking — тоже «думаю...».
assertEquals(
"думаю...",
overlayHeader(listening.copy(thinking = true)),
)
}
@Test
fun listeningHeaderFallsBackToListening() {
assertEquals("слушаю...", overlayHeader(listening))
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
}
@Test
fun idleHeaderHintsGestures() {
assertEquals("клик — говорить · двойной — выход", overlayHeader(idle))
// В ответе подсказка та же — чат прокручивается, жесты те же.
assertEquals("клик — говорить · двойной — выход", overlayHeader(answer))
}
/** Пустой клик без голоса — idle-кадр с «не расслышал». */
@Test
fun noSpeechIdleShowsHint() {
val noSpeech = idle.copy(sttNoSpeech = true)
assertEquals(AssistantMode.IDLE, assistantMode(noSpeech))
assertEquals("не расслышал — клик, чтобы повторить", overlayHeader(noSpeech))
assertTrue(overlayVisible(noSpeech))
}
// ————— старт отклонён: модель не скачалась —————
@Test
fun overlayVisibleWhenStartBlocked() {
assertTrue(overlayVisible(blocked))
assertFalse(overlayVisible(movie))
}
@Test
fun startBlockedHeaderIsModelNotDownloaded() {
// Блок бьёт и обычный «думаю...» — заголовок всегда «модель не скачалась».
assertEquals("модель не скачалась", overlayHeader(blocked))
assertEquals(
"модель не скачалась",
overlayHeader(blocked.copy(assistantText = "ответ")),
)
}
// ————— строка статуса модели —————
@Test
fun modelStatusLineReadyIsNull() {
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
}
@Test
fun modelStatusLineDownloadingShowsPercent() {
assertEquals(
"модель качается: 45%",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
)
}
@Test
fun modelStatusLineMissingAndFailed() {
assertEquals(
"модель не скачана — распознавание недоступно",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
)
assertEquals(
"модель не скачалась — повторю автоматически",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
)
}
// ————— скролл ответа —————
@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,86 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SttModelProviderTest {
private fun newModelDir(): File {
val dir = Files.createTempDirectory("stt-model-test").toFile()
dir.deleteOnExit()
return dir
}
private fun smallFiles() = listOf(
ModelFile("a.onnx", 4L, "sha-a"),
ModelFile("tokenizer/tok.json", 5L, "sha-b"),
)
private fun write(modelDir: File, relPath: String, size: Int) {
val f = File(modelDir, relPath)
f.parentFile?.mkdirs()
f.writeBytes(ByteArray(size))
}
@Test
fun manifestIsSane() {
assertEquals(6, SttModel.FILES.size)
assertEquals(6, SttModel.FILES.map { it.relPath }.toSet().size, "пути дублируются")
SttModel.FILES.forEach { f ->
assertTrue(f.sha256Hex.length == 64, "некорректный SHA-256: ${f.relPath}")
assertTrue(f.sizeBytes > 0, "нулевой размер: ${f.relPath}")
}
// Иерархия 1:1 с filesDir/models телефона: 3 корневых .onnx + tokenizer/ (3 файла).
assertTrue(SttModel.FILES.any { it.relPath == "conv_frontend.onnx" })
assertTrue(SttModel.FILES.any { it.relPath == "tokenizer/merges.txt" })
}
@Test
fun emptyDirNeedsAllFiles() {
val dir = newModelDir()
val files = smallFiles()
val needed = whichFilesNeeded(dir, files)
assertEquals(files, needed)
assertTrue(!isModelComplete(dir, files))
}
@Test
fun completeDirNeedsNothing() {
val dir = newModelDir()
val files = smallFiles()
write(dir, "a.onnx", 4)
write(dir, "tokenizer/tok.json", 5)
assertEquals(emptyList(), whichFilesNeeded(dir, files))
assertTrue(isModelComplete(dir, files))
}
@Test
fun wrongSizeTreatedAsNeeded() {
val dir = newModelDir()
val files = smallFiles()
write(dir, "a.onnx", 3) // короче ожидаемых 4
write(dir, "tokenizer/tok.json", 5)
val needed = whichFilesNeeded(dir, files)
assertEquals(listOf("a.onnx"), needed.map { it.relPath })
}
@Test
fun modelTotalBytesIsSum() {
assertEquals(9L, modelTotalBytes(smallFiles()))
assertTrue(
modelTotalBytes() in 900_000_000..1_000_000_000,
"сумма байт модели не в ожидаемом порядке: ${modelTotalBytes()}",
)
}
@Test
fun sha256HexOfKnownInput() {
val dir = newModelDir()
val f = File(dir, "known.bin")
f.writeBytes("abc".encodeToByteArray())
assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", sha256Hex(f))
}
}
@@ -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)
}
}
+54 -1
View File
@@ -1,4 +1,5 @@
import java.util.Properties import java.util.Properties
import java.io.File
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@@ -7,6 +8,20 @@ plugins {
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
} }
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
val binomCertsDir: File = File(
(project.findProperty("binom.keystore.dir") as? String)
?: System.getenv("BINOM_KEYSTORE_DIR")
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
)
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
val binomCreds: Map<String, String> =
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
else emptyMap() })
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан. // LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную. // providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
val llmApiKey: String = run { val llmApiKey: String = run {
@@ -47,9 +62,30 @@ android {
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"") buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
} }
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
// Без кестора binomCreds пусто → release остаётся неподписанным.
if (binomCreds.isNotEmpty()) {
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
signingConfigs {
create("binom") {
storeFile = binomStoreFile
storePassword = binomStorePass
keyAlias = binomAlias
keyPassword = binomKeyPass
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
} }
} }
@@ -94,13 +130,28 @@ 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")
// QR-код для паринга очков (pairCode) — рендер BitMatrix->Bitmap
implementation("com.google.zxing:core:3.5.2")
// 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)
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack // LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU):
// KMP-обёртка pw.binom.litert (универсальный контракт RAW-инференса + google-бэкенд)
implementation(libs.litert.google)
// универсальный контракт (LiteLlm/LiteRequest…) — импортируется напрямую
implementation(libs.litert.api)
// Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), 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")
// Офлайн-распознавание Qwen3-ASR (asr-kmp, caffeine nexus) — Qwen3AsrStt
implementation("pw.binom.asr:asr-qwen3-android:4")
// WS-сервер телефона (CIO — работает на Android) // WS-сервер телефона (CIO — работает на Android)
implementation("io.ktor:ktor-server-core:3.3.0") implementation("io.ktor:ktor-server-core:3.3.0")
implementation("io.ktor:ktor-server-cio:3.3.0") implementation("io.ktor:ktor-server-cio:3.3.0")
@@ -108,6 +159,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.
+28 -1
View File
@@ -7,7 +7,19 @@
<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.BLUETOOTH_ADVERTISE" />
<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 +28,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>
@@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -20,34 +22,66 @@ import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File import java.io.File
import kotlin.math.abs import kotlin.math.abs
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */ /** Решение одного sync-цикла: speed — желаемая скорость аудио; seekToMs не null → редкий жёсткий seek; playing не null → play/pause. */
internal data class SyncDecision( internal data class SyncDecision(
val speed: Float = 1f,
val seekToMs: Long? = null, val seekToMs: Long? = null,
val playing: Boolean? = null, val playing: Boolean? = null,
) )
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */ /** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
internal const val SYNC_THRESHOLD_MS = 1000L internal const val TRIGGER_BAND_MS = 150L
/**
* Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f).
* 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED].
*/
internal const val DRIFT_GAIN = 0.0002f
/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */
internal const val MAX_DRIFT_SPEED = 0.10f
/**
* Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo.
* Поднят с 2000 до 5000: обычная буферная пауза вывода (0.5–1.5 с) при локальном файле не должна
* форсировать жёсткий seek (он перебуферизирует ExoPlayer и даёт слышимую тишину + прыжок позиции);
* умеренный рассинхрон закрывается плавной коррекцией скоростью, бесшовно.
*/
internal const val HARD_SEEK_THRESHOLD_MS = 5000L
/** EWMA-сглаживание измеренной латентности линка (доля старого значения). */
internal const val LINK_LATENCY_ALPHA = 0.8
/** Верхний предел измеренной латентности: выше — артефакт (обрыв/зависание), игнорируем. */
internal const val LINK_LATENCY_MAX_MS = 2000.0
/** /**
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android): * Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |audio - video| > [thresholdMs] → перемотать на позицию очков; * - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
* - isPlaying очков отличается от состояния аудио → play/pause. * - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем),
* звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается
* [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно;
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
*/ */
internal fun decideSync( internal fun decideSync(
audioPosition: Long, audioPosition: Long,
videoPosition: Long, videoPosition: Long,
audioPlaying: Boolean, audioPlaying: Boolean,
videoPlaying: Boolean, videoPlaying: Boolean,
thresholdMs: Long = SYNC_THRESHOLD_MS,
): SyncDecision { ): SyncDecision {
val seekToMs = if (abs(audioPosition - videoPosition) > thresholdMs) videoPosition else null val diff = audioPosition - videoPosition
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
val speed = when {
seekToMs != null -> 1f
abs(diff) <= TRIGGER_BAND_MS -> 1f
else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED)
}
val playing = when { val playing = when {
videoPlaying == audioPlaying -> null videoPlaying == audioPlaying -> null
videoPlaying -> true videoPlaying -> true
else -> false else -> false
} }
return SyncDecision(seekToMs = seekToMs, playing = playing) return SyncDecision(speed = speed, seekToMs = seekToMs, playing = playing)
} }
/** /**
@@ -115,15 +149,51 @@ class AudioSyncPlayer(private val context: Context) {
private var player: ExoPlayer? = null private var player: ExoPlayer? = null
private var ticker: Job? = null private var ticker: Job? = null
/** Позиция очков из последнего sync (для догонки после неточного seekTo). */ /** Слушатель буферизации ExoPlayer: логирует паузы вывода с длительностью (причина «немого» звука:
* длительная буферизация = локальная/S3-дорога, короткая/абсолютное отсутствие = BT-стек). */
private val bufferingListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> {
bufferingStartMs = System.currentTimeMillis()
log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)")
}
Player.STATE_READY -> {
val bufferedMs = if (bufferingStartMs > 0L) System.currentTimeMillis() - bufferingStartMs else 0L
bufferingStartMs = 0L
log("audio", "буфер готов, вывод возобновлён${if (bufferedMs > 0L) " (буферизация ${bufferedMs} мс)" else ""}")
}
}
}
}
/** Позиция очков из последнего sync. */
private var lastVideoPositionMs = 0L private var lastVideoPositionMs = 0L
/** IsPlaying очков из последнего sync. */ /** IsPlaying очков из последнего sync. */
private var lastVideoPlaying = false private var lastVideoPlaying = false
/** Сглаженная (EWMA) латентность линка очки→телефон (мс): запаздывание мастер-позиции. */
private var linkLatencyMs = 0.0
private var lastLatencyLoggedAt = 0L
/** Текущий источник для логов: «стриминг S3» или «локальный файл» — виден в каждой периодической
* строке «audio sync:», чтобы любой фрагмент logcat отвечал на вопрос «стримлю или скачанное?». */
private var sourceLabel = "нет"
/** Момент входа в STATE_BUFFERING (для лога длительности; 0 — не в буферизации). */
private var bufferingStartMs = 0L
private val _positionMs = MutableStateFlow(0L) private val _positionMs = MutableStateFlow(0L)
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */ /** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
val positionMs: StateFlow<Long> = _positionMs.asStateFlow() val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
private val _diffMs = MutableStateFlow(0L)
/** |звук − видео| из последнего sync-цикла (мс), для индикатора статус-панели очков. 0 — синхронизации не было. */
val diffMs: StateFlow<Long> = _diffMs.asStateFlow()
/** Играет ли видео на очках (по последнему PlaybackPosition). Только с главного потока. */
val videoPlaying: Boolean
get() = lastVideoPlaying
/** Есть ли активный аудио-плеер (просмотр на очках). */ /** Есть ли активный аудио-плеер (просмотр на очках). */
val isActive: Boolean val isActive: Boolean
get() = player != null get() = player != null
@@ -139,43 +209,62 @@ class AudioSyncPlayer(private val context: Context) {
} else { } else {
Uri.fromFile(File(urlOrFile)) Uri.fromFile(File(urlOrFile))
} }
val p = player ?: ExoPlayer.Builder(context).build().also { player = it } val p = player ?: ExoPlayer.Builder(context).build().also {
it.addListener(bufferingListener)
player = it
}
p.setMediaItem(MediaItem.fromUri(uri)) p.setMediaItem(MediaItem.fromUri(uri))
p.prepare() p.prepare()
p.seekTo(startPositionMs.coerceAtLeast(0L)) p.seekTo(startPositionMs.coerceAtLeast(0L))
p.playWhenReady = true p.playWhenReady = true
log("audio", "играю $urlOrFile с ${startPositionMs} мс") val isStream = urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://")
sourceLabel = if (isStream) "стриминг S3" else "локальный файл"
log("audio", "играю $sourceLabel: $urlOrFile с ${startPositionMs} мс")
startTicker() startTicker()
} }
} }
/** /**
* Коррекция по позиции очков: diff > 1000 мс → seek, play/pause по isPlaying. * Коррекция по позиции очков: за пределами гистерезис-зоны скорость плавно
* Пишет ключевой лог для QA при каждом вызове. * подгоняется через setPlaybackParameters (без seekTo); жёсткий seek — только
* при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f.
* Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове.
*/ */
fun sync(positionMs: Long, isPlaying: Boolean) { fun sync(positionMs: Long, isPlaying: Boolean, sentTs: Long = 0L) {
main.post { main.post {
val p = player ?: return@post val p = player ?: return@post
lastVideoPositionMs = positionMs lastVideoPositionMs = positionMs
lastVideoPlaying = isPlaying lastVideoPlaying = isPlaying
val audioPos = p.currentPosition // Латентность линка очки→телефон: сколько мс прошло от отправки позиции на очках до обработки здесь.
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying) // Часы синхронизированы через mercury.syncTime(), так что это чистая транспортная задержка.
decision.seekToMs?.let { if (sentTs > 0L) {
p.seekTo(it) val measured = (System.currentTimeMillis() - sentTs).toDouble()
lastVideoPositionMs = it if (measured in 0.0..LINK_LATENCY_MAX_MS) {
// Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с), linkLatencyMs = linkLatencyMs * LINK_LATENCY_ALPHA + measured * (1 - LINK_LATENCY_ALPHA)
// второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с). val now = System.currentTimeMillis()
main.postDelayed({ if (now - lastLatencyLoggedAt > 5000) {
val pp = player ?: return@postDelayed lastLatencyLoggedAt = now
val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду log("audio sync:", "латентность линка: ${linkLatencyMs.toInt()} мс")
if (abs(pp.currentPosition - target) > 500) {
pp.seekTo(target)
log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})")
} }
}, 1_000) }
}
// Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел).
val videoPos = positionMs + linkLatencyMs.toLong()
val audioPos = p.currentPosition
_diffMs.value = abs(audioPos - videoPos)
val decision = decideSync(audioPos, videoPos, p.isPlaying, isPlaying)
when {
decision.seekToMs != null -> {
p.seekTo(decision.seekToMs)
p.setPlaybackParameters(PlaybackParameters(1f))
log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)} src=$sourceLabel)")
}
else -> {
p.setPlaybackParameters(PlaybackParameters(decision.speed))
log("audio sync:", "video=$videoPos audio=$audioPos diff=${abs(audioPos - videoPos)} speed=${decision.speed} src=$sourceLabel")
}
} }
decision.playing?.let { if (it) p.play() else p.pause() } decision.playing?.let { if (it) p.play() else p.pause() }
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
} }
} }
@@ -198,7 +287,10 @@ class AudioSyncPlayer(private val context: Context) {
ticker = null ticker = null
val p = player val p = player
player = null player = null
lastVideoPlaying = false
sourceLabel = "нет"
_positionMs.value = 0L _positionMs.value = 0L
_diffMs.value = 0L
if (p != null) { if (p != null) {
p.playWhenReady = false p.playWhenReady = false
p.stop() p.stop()
@@ -0,0 +1,205 @@
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 — для сканирования; ADVERTISE — для GATT-сервера
* очков, Mercury-управление),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31) {
arrayOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE,
)
} 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,251 @@
package pw.binom.viewmate.phone
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"},"pairing":${s.pairCode != null},"pairCode":${s.pairCode?.let { "\"$it\"" } ?: "null"},"realAddress":${s.realAddress?.let { "\"$it\"" } ?: "null"}}"""
code = 200
}
method == "GET" && path.startsWith("/mercury/bonded") -> {
val bonds = MercuryBridge.bondedAddresses()
val items = bonds.joinToString(",") { (addr, name) ->
"""{"address":"$addr","name":${name?.let { jsonEscape(it) } ?: "null"}}"""
}
response = """{"count":${bonds.size},"bonds":[$items]}"""
code = 200
}
method == "GET" && path.startsWith("/mercury/discover") -> {
val seconds = queryParams["time"]?.toIntOrNull() ?: 5
val found = MercuryBridge.discoverGlasses(seconds * 1_000L)
val items = found.joinToString(",") { (name, addr) ->
"""{"name":${name?.let { jsonEscape(it) } ?: "null"},"address":"$addr"}"""
}
val s = MercuryBridge.state.value
response = """{"count":${found.size},"found":[$items],"connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/pair/stop") -> {
MercuryBridge.cancelPairing()
val s = MercuryBridge.state.value
response = """{"ok":true,"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/pair/classic") -> {
val address = queryParams["address"]
if (address.isNullOrBlank()) {
response = """{"error":"нет параметра address"}"""
code = 400
} else {
val ok = MercuryBridge.pairClassic(address)
val s = MercuryBridge.state.value
response = """{"ok":$ok,"address":"$address","connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
}
method == "POST" && path.startsWith("/mercury/pair") -> {
val pairCode = MercuryBridge.startPairing()
val s = MercuryBridge.state.value
response = """{"ok":${pairCode != null},"pairCode":${pairCode?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/unpair") -> {
val address = queryParams["address"]
if (address.isNullOrBlank()) {
response = """{"error":"нет параметра address"}"""
code = 400
} else {
val ok = MercuryBridge.unpair(address)
response = """{"ok":$ok,"address":"$address"}"""
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
}
method == "POST" && path.startsWith("/mercury/wifi") -> {
val enable = "true".equals(queryParams["enable"], ignoreCase = true)
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiToggle(enable) }
response = """{"ok":$ok,"enable":$enable,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/wifiscan") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiScan() }
val s = MercuryBridge.state.value
response = """{"ok":$ok,"networks":${s.wifiNetworks.size},"status":${jsonEscape(s.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,19 @@
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.log.LogEntry
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
@@ -27,10 +23,12 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Gesture import pw.binom.viewmate.core.protocol.Gesture
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.LogBatchMsg
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SttAudio import pw.binom.viewmate.core.protocol.SttAudio
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.SttText
import pw.binom.viewmate.core.protocol.StopStt import pw.binom.viewmate.core.protocol.StopStt
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
@@ -41,14 +39,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()
@@ -96,46 +98,58 @@ class GlassesHub(
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после * с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
* рестарта телефона (см. PhoneApp.autostartAudio). * рестарта телефона (см. PhoneApp.autostartAudio).
*/ */
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long, durationMs: Long) -> Unit)? = null
/**
* Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
* (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
*/
@Volatile
var onLogBatch: ((List<LogEntry>) -> Unit)? = null
/** /**
* Стриминговое распознавание речи с очков. Лениво создаётся через * Стриминговое распознавание речи с очков. Лениво создаётся через
* [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 +158,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 -> {
@@ -182,13 +196,18 @@ class GlassesHub(
this.state.playing = msg.playing this.state.playing = msg.playing
val state = if (msg.playing) "играет" else "пауза" val state = if (msg.playing) "играет" else "пауза"
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}") log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}")
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex) onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs, msg.durationMs)
} }
is GlassesOff -> { is GlassesOff -> {
log("glasses", "очки выключились → пауза (${msg.reason})") log("glasses", "очки выключились → пауза (${msg.reason})")
this.state.playing = false this.state.playing = false
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L, -1L)
}
is LogBatchMsg -> {
log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
onLogBatch?.invoke(msg.entries)
} }
is SttAudio -> { is SttAudio -> {
@@ -199,80 +218,89 @@ 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)
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) { } else {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg) // Фразы не было — очки возвращаются в «слушаю» (SttCancel).
session.send(text) log("stt", "фразы не было — очки в «слушаю»")
} broadcast(SttCancel(reason = SttCancel.REASON_USER))
}
/** 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 { // в LLM тем же каналом, что и StopStt(cancel=false). Old SttAudio/StopStt
hub.remove(this) // остаются фолбэком для старых очей.
is SttText -> {
val full = msg.text.trim()
if (full.isNotBlank()) {
log("stt", "STT-текст от очков (локально): $full → LLM")
onStopFullText?.invoke(full)
} else {
log("stt", "STT-текст от очков пуст — ignore")
}
} }
} }
} }
private suspend fun send(connId: String, msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
runCatching { transport.send(connId, text) }
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
}
} }
/** /**
* 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,31 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
/** Единый лог телефона: простой println (работает и в unit-тестах). */ import android.util.Log
fun log(tag: String, message: String) { import pw.binom.viewmate.core.log.BatchingLogCollector
println("[view-mate] $tag $message")
/**
* Единый лог телефона.
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
* (android.util.Log недоступен — метод без @JvmStatic фолбэктнет в println),
* чтобы не падать.
*
* Плюс лог-коллектор: пачки (свои + пришедшие от очков) уходят на сервер
* (HttpLogSink); при сбое — прихраняются в споуле и ретраятся позже.
*/
@Volatile
private var logCollector: BatchingLogCollector? = null
/** Подключить коллектор (PhoneApp.onCreate). */
fun attachLogCollector(collector: BatchingLogCollector) {
logCollector = collector
}
fun log(tag: String, message: String) {
val full = "[view-mate] $tag $message"
runCatching {
Log.d("view-mate-$tag", full)
}.onFailure {
println(full)
}
logCollector?.record(tag, message)
} }
@@ -1,12 +1,16 @@
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
import android.view.WindowManager
import android.widget.FrameLayout 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 +19,8 @@ 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()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent { setContent {
ViewMateApp() ViewMateApp()
} }
@@ -26,6 +32,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,423 @@
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
import pw.binom.mercury.model.MyWifiInfo
import pw.binom.mercury.pair.PairManager
/**
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
*
* Минимум для полевого теста: скан → pair → connect → погасить/включить экран
* (deepSuspend). События либы логируются и частично проксируются в [status].
*
* Кнопки экрана — только проверка транспорта: если очки принимают команду,
* протокол жив, дальше можно вешать остальные команды (яркость, WiFi, магазин).
*/
object MercuryBridge {
private const val TAG = "MercuryBridge"
/** Пакет нашего очкового приложения (applicationId из app-glasses). */
const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses"
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,
val brightness: Int = 0,
val automaticBrightness: Boolean = false,
val wifiEnabled: Boolean = false,
val wifiSsid: String? = null,
val wifiNetworks: List<MyWifiInfo> = emptyList(),
/** Активный pairCode для QR-паринга (не null, пока очки не подключились). */
val pairCode: String? = null,
/** Реальный классический BT-адрес очков, полученный в pre-bond окне (null до паринга). */
val realAddress: 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, pairCode = null)
setStatus("подключено (BLE)")
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
// ползунок яркости и переключатель WiFi показывали реальные значения.
scope.launch { mercury?.pullGeneralStatus() }
// Синхронизируем часы очков с телефоном: до NTP очки стартуют с
// нулевой меткой (~1970), и логи получают «старые» таймстемпы,
// из-за чего Loki отвергает весь batch (HTTP 400) и спул клинит.
scope.launch { mercury?.syncTime() }
// Включаем 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 -> {
val s = event.status
_state.value = _state.value.copy(
brightness = s.brightness,
automaticBrightness = s.automaticBrightness,
wifiEnabled = s.wifiEnabled,
wifiSsid = s.wifiInfo?.ssid,
)
log("status", "brightness=${s.brightness} autoBrightness=${s.automaticBrightness} deepSuspend=${s.deepSuspend} wifi=${s.wifiEnabled} ssid=${s.wifiInfo?.ssid}")
}
is RayNeoMercury.Event.WifiScanResult -> {
_state.value = _state.value.copy(wifiNetworks = event.networks)
log("wifi", "сетей: ${event.networks.size}")
}
is RayNeoMercury.Event.RealBluetoothAddress -> {
// Pre-bond handshake: очки сообщили свой реальный классический
// BT-адрес (он же ожидает QR-pairCode). Создаём OS-level bond —
// это и есть «паринг» (в стоке: PairStateMachine → AutoClassic → createBond).
log("pair", "real bt address: ${event.address} name=${event.name} type=${event.type}")
_state.value = _state.value.copy(realAddress = event.address)
setStatus("получен адрес ${event.address}, настраиваю связь (bond)…")
scope.launch {
val m = mercury
val res = if (m != null) m.pairByRealAddress(event.address)
else PairManager.PairingResult.Failed("нет связи с библиотекой")
when (res) {
is PairManager.PairingResult.Success -> {
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
setStatus("спаривание успешно: ${event.address}, подключаюсь…")
}
is PairManager.PairingResult.Failed -> {
setStatus("спаривание не удалось: ${res.reason}")
}
}
}
}
}
}
}
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 &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == 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
}
// Без bond очки не отдают GATT-сервис — спариваем и пробуем ещё раз.
setStatus("connect не удался, спариваю…")
when (val res = m.pair(glasses)) {
is PairManager.PairingResult.Success -> {
if (m.connect(glasses, 8000)) {
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
setStatus("подключено (клиент, после pair)")
return true
}
}
is PairManager.PairingResult.Failed -> {
setStatus("спаривание не удалось: ${res.reason}")
}
}
}
}
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
}
/**
* Запустить приложение на очках по пакету (протокол «app-market», [RayNeoMercury.appMarketLaunch]).
* Очки сами открывают приложение по [pkg]; [extras] — опциональные аргументы (как Intent).
*/
suspend fun launchApp(pkg: String, extras: Map<String, String>? = null): Boolean {
val ok = mercury?.appMarketLaunch(pkg, extras) ?: false
setStatus(if (ok) "запуск приложения $pkg на очках" 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
}
/** Яркость экрана очков (0..100). Авто-яркость принудительно выключается — иначе очки игнорируют ручное значение. */
suspend fun setBrightness(value: Int): Boolean {
val v = value.coerceIn(0, 100)
// При automaticBrightness=true очки отбрасывают brightness, поэтому выключаем авто заранее.
mercury?.setAutoBrightness(false)
val ok = mercury?.setBrightness(v) ?: false
if (ok) _state.value = _state.value.copy(brightness = v, automaticBrightness = false)
setStatus(if (ok) "яркость → $v%" else "ошибка отправки (нет связи?)")
return ok
}
/** Авто-яркость очков. */
suspend fun setAutoBrightness(enable: Boolean): Boolean {
val ok = mercury?.setAutoBrightness(enable) ?: false
if (ok) _state.value = _state.value.copy(automaticBrightness = enable)
setStatus(if (ok) "авто-яркость → $enable" else "ошибка отправки (нет связи?)")
return ok
}
/** Вкл/выкл WiFi-радио очков. */
suspend fun wifiToggle(enable: Boolean): Boolean {
val ok = mercury?.wifiToggle(enable) ?: false
if (ok) _state.value = _state.value.copy(wifiEnabled = enable)
setStatus(if (ok) "WiFi → ${if (enable) "вкл" else "выкл"}" else "ошибка отправки (нет связи?)")
return ok
}
/** Подключить очки к WiFi-сети (пароль null, если открытая). */
suspend fun wifiConnect(ssid: String, password: String?): Boolean {
val ok = mercury?.wifiConnect(ssid, password) ?: false
setStatus(if (ok) "WiFi: подключение к «$ssid»…" else "ошибка отправки (нет связи?)")
return ok
}
/** Забыть сохранённую WiFi-сеть на очках. */
suspend fun wifiRemove(ssid: String): Boolean {
val ok = mercury?.wifiRemove(ssid) ?: false
setStatus(if (ok) "WiFi: забыть «$ssid»" else "ошибка отправки (нет связи?)")
return ok
}
/** Сканировать доступные WiFi-сети (результат → state.wifiNetworks). */
suspend fun wifiScan(): Boolean {
val ok = mercury?.wifiScan() ?: false
setStatus(if (ok) "WiFi: сканирование…" else "ошибка отправки (нет связи?)")
return ok
}
/** Запросить актуальный статус очков (яркость/WiFi/батарея). */
suspend fun pullStatus(): Boolean {
val ok = mercury?.pullGeneralStatus() ?: false
if (!ok) setStatus("ошибка запроса статуса (нет связи?)")
return ok
}
/**
* Старт паринга: перезапускает GATT-сервер с pairCode в рекламе и отдаёт код
* для QR. Пользователь показывает QR экрану очков — они его сканируют,
* подключаются в pre-bond окне и шлют свой реальный адрес
* (Event.RealBluetoothAddress), по которому мы делаем createBond.
*/
fun startPairing(): String? {
val m = mercury ?: return null
if (m.isAnyConnected) {
setStatus("очки уже подключены — паринг не нужен")
return null
}
val code = m.startPairing()
_state.value = _state.value.copy(pairCode = code)
setStatus("паринг: покажите QR экрану очков (код $code)")
return code
}
/** Отмена активного QR-паринга (прячет QR, перезапускает сервер в обычном режиме без pairCode). */
fun cancelPairing() {
_state.value = _state.value.copy(pairCode = null)
mercury?.stopPairing()
setStatus("паринг отменён")
}
/**
* Забывает очки (removeBond). Применяется, когда очки на своей стороне
* потеряли бонд, а телефон держит «старый» бонд — рассинхрон блокирует
* нормальное повторное паринг.
*/
fun unpair(address: String): Boolean {
val m = mercury ?: return false
return kotlinx.coroutines.runBlocking { m.unpairAddress(address) }
}
/** Текущие классические BT-бонды телефона (адрес → имя). Debug-помощник. */
fun bondedAddresses(): List<Pair<String, String?>> =
mercury?.bondedAddresses() ?: emptyList()
/**
* Классический паринг по известному классическому адресу — ручной путь без
* QR и камеры очков (то самое «подключите вручную в приложении» с экрана
* очков). createBond по адресу; очки после BONDED сами уходят из паринг-режима.
*/
fun pairClassic(address: String): Boolean {
val m = mercury ?: return false
val res = kotlinx.coroutines.runBlocking { m.pairByRealAddress(address) }
return if (res is PairManager.PairingResult.Success) {
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
setStatus("классический паринг успешен: $address (${res.device.name})")
true
} else {
setError("классический паринг не удался: ${(res as? PairManager.PairingResult.Failed)?.reason}")
false
}
}
/**
* Классический discovery: находит RayNeo-очки в режиме первого паринга вокруг
* телефона (без камеры). Возвращает список найденных (имя, адрес).
*/
fun discoverGlasses(timeoutMs: Long = 5_000): List<Pair<String, String?>> {
val m = mercury ?: return emptyList()
val found = kotlinx.coroutines.runBlocking { m.discoverClassic(timeoutMs) }
return found.map { it.device.name to it.address }
}
/**
* Ручной паринг «в один клик»: классический discovery, выбор RayNeo-очков и
* createBond. Кнопка вместо QR — для случая, когда очки снова в режиме первого
* паринга (слетел бонд) и камера очков недоступна/не считывает QR.
*/
fun pairClassicAuto(timeoutMs: Long = 7_000): String? {
val found = discoverGlasses(timeoutMs)
if (found.isEmpty()) {
setError("ручной паринг: рядом не найдено ни одного классического устройства — включите очки")
return null
}
val target = found.firstOrNull { (it.first)?.contains("rayneo", ignoreCase = true) == true }
if (target == null) {
setError("ручной паринг: RayNeo рядом не найден (проверьте, что очки включены и в режиме паринга)")
return null
}
val address = target.second
val ok = pairClassic(address ?: return null)
if (!ok) return null
setStatus("ручной паринг: очки подключены ($address)")
return address
}
private fun setStatus(text: String) {
log("status", text)
_state.value = _state.value.copy(statusText = text, lastError = null)
}
private fun setError(text: String) {
log("mercury", 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) {
pw.binom.viewmate.phone.log(tag, msg)
}
}
@@ -0,0 +1,310 @@
package pw.binom.viewmate.phone
import ai.koog.prompt.executor.clients.openai.base.models.OpenAIMessage
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import io.ktor.server.response.respondTextWriter
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.viewmate.phone.agent.ChatMessage
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.LlmClient
import java.util.UUID
/**
* OpenAI-совместимый HTTP-сервер (Ktor-CIO, в стиле [WifiServerTransport]):
* - POST /v1/chat/completions — stream=false → JSON chat.completion,
* stream=true → SSE-чанки chat.completion.chunk + [DONE];
* - GET /v1/models, GET / — самодокументация.
*
* Мессиджи запроса парсятся DTO koog [OpenAIMessage] (парсер OpenAI-клиента:
* роль → типизированный сабтип, content — строка или массив parts,
* tool + tool_call_id). Ответы — ручной snake_case JSON: DTO koog для
* ответов (OpenAIChatCompletionResponse) сериализуют finishReason в
* camelCase (клиентская конвенция), что не OpenAI wire-формат.
*
* Бэкенд — [PhoneApp.openAiLlm] (текущий выбор модели: локальный LiteRT-LM
* или удалённый по ключу); null → 503. Ядро [handleChat] вынесено из
* сокета/корутины Ktor — юнит-тестится на JVM.
*/
object OpenAiHttpServer {
private const val MODEL_NAME = "viewmate"
/** Задержка между SSE-фреймами эмулированного стрима (движок не стримит нативно). */
private const val CHUNK_DELAY_MS = 25L
private val json = Json { ignoreUnknownKeys = true }
@Volatile
private var started = false
private var server: EmbeddedServer<*, *>? = null
/** Поднимает Ktor-сервер на [PhoneConfig.OPENAI_SERVER_PORT] и печатает все IP устройства. */
fun start(app: PhoneApp) {
if (started) return
started = true
try {
server = embeddedServer(CIO, port = PhoneConfig.OPENAI_SERVER_PORT, host = "0.0.0.0") {
openAiModule(app)
}
server!!.start(wait = false)
log("openai", "OpenAI-HTTP (Ktor-CIO) на 0.0.0.0:${PhoneConfig.OPENAI_SERVER_PORT}")
log("openai", "Эндпоинты: /v1/chat/completions (stream=true|false), /v1/models, /")
allIps().forEach { ip ->
log("openai", "IP: $ip → http://$ip:${PhoneConfig.OPENAI_SERVER_PORT}/v1/chat/completions")
}
} catch (e: Exception) {
log("openai", "не удалось запустить: ${e.message}")
}
}
// ---------- Ktor-модуль ----------
private fun Application.openAiModule(app: PhoneApp) {
routing {
post("/v1/chat/completions") {
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
when (val outcome = handleChat(app.openAiLlm(), call.receiveText())) {
is ChatOutcome.Json -> call.respondText(
text = outcome.body,
contentType = ContentType.Application.Json,
status = statusCode(outcome.code),
)
is ChatOutcome.Sse -> sendSse(call, outcome)
}
}
get("/v1/models") {
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
call.respondText(modelsJson(), ContentType.Application.Json)
}
get("/") {
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
call.respondText(helpJson(), ContentType.Application.Json)
}
}
}
/** SSE-стрим: фреймы [ChatOutcome.Sse.frames] с задержкой [CHUNK_DELAY_MS]. */
private suspend fun sendSse(call: ApplicationCall, sse: ChatOutcome.Sse) {
call.respondTextWriter(
contentType = ContentType.parse("text/event-stream; charset=utf-8"),
status = HttpStatusCode.OK,
) {
sse.frames.forEach { frame ->
append(frame)
flush()
delay(CHUNK_DELAY_MS)
}
}
}
private fun statusCode(code: Int): HttpStatusCode = when (code) {
200 -> HttpStatusCode.OK
400 -> HttpStatusCode.BadRequest
502 -> HttpStatusCode.BadGateway
503 -> HttpStatusCode.ServiceUnavailable
else -> HttpStatusCode(code, "Error")
}
// ---------- ядро (без сокета: юнит-тестится) ----------
/** Итог обработки /v1/chat/completions: JSON-тело с кодом или список SSE-фреймов. */
internal sealed interface ChatOutcome {
data class Json(val code: Int, val body: String) : ChatOutcome
data class Sse(val frames: List<String>) : ChatOutcome
}
internal suspend fun handleChat(llm: LlmClient?, body: String): ChatOutcome {
val req = try {
json.decodeFromJsonElement(OaiRequest.serializer(), normalizeOpenAiElement(body))
} catch (e: Exception) {
return ChatOutcome.Json(400, errorJson("некорректный JSON body", 400))
}
val (system, history) = runCatching { splitMessages(req) }.getOrElse { e ->
return ChatOutcome.Json(400, errorJson(e.message ?: "некорректные messages", 400))
}
if (history.isEmpty()) return ChatOutcome.Json(400, errorJson("messages не может быть пустым", 400))
if (llm == null) {
return ChatOutcome.Json(503, errorJson("LLM недоступен: API-ключ не задан или модель не загружена", 503))
}
val text = try {
llm.chat(history, system)
} catch (e: Exception) {
log("openai", "ошибка LLM: ${e.message}")
return ChatOutcome.Json(502, errorJson("ошибка LLM: ${e.message}", 502))
}
log("openai", "запрос: stream=${req.stream}, msg=${req.messages.size} → ${text.length} симв")
val id = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "").take(12)
val created = System.currentTimeMillis() / 1000
return if (req.stream) ChatOutcome.Sse(streamFrames(id, created, text))
else ChatOutcome.Json(200, completionJson(id, created, text))
}
/**
* OpenAI wire → DTO koog: tool-сообщения несут `tool_call_id` (snake_case),
* а парсер koog ждёт `toolCallId` (camelCase, клиентская конвенция).
* Для tool-роли дописываем camelCase-копию (или пустую, если поля нет).
* Остальное — без изменений.
*/
private fun normalizeOpenAiElement(raw: String): JsonElement {
val root = json.decodeFromString(JsonElement.serializer(), raw)
val obj = root as? JsonObject ?: return root
val rawMsgs = obj["messages"] as? JsonArray ?: return root
val fixed = rawMsgs.map { el ->
val m = el as? JsonObject ?: return@map el
if (m["role"]?.jsonPrimitive?.contentOrNull == "tool" && !m.containsKey("toolCallId")) {
val tcid = m["tool_call_id"]?.jsonPrimitive?.contentOrNull ?: ""
JsonObject(m.toMutableMap().apply { put("toolCallId", JsonPrimitive(tcid)) })
} else el
}
return JsonObject(obj.toMutableMap().apply { put("messages", JsonArray(fixed)) })
}
/**
* Мессиджи OpenAI → (system-промпт, история): system/developer склеиваются
* в system; user/assistant/tool (tool_call_id) → [ChatMessage].
* Content у DTO koog: строка или массив parts — [ai.koog.prompt.executor.clients.openai.base.models.Content.text].
*/
private fun splitMessages(req: OaiRequest): Pair<String?, List<ChatMessage>> {
val systems = mutableListOf<String>()
val history = mutableListOf<ChatMessage>()
for (m in req.messages) {
val content = m.content?.text().orEmpty()
when (m) {
is OpenAIMessage.System, is OpenAIMessage.Developer -> systems.add(content)
is OpenAIMessage.User -> history.add(ChatMessage.of(ChatRole.USER, content))
is OpenAIMessage.Assistant -> history.add(ChatMessage.of(ChatRole.ASSISTANT, content))
is OpenAIMessage.Tool ->
history.add(ChatMessage.of(ChatRole.TOOL, content, toolCallId = m.toolCallId))
}
}
val system = systems.joinToString("\n").ifEmpty { null }
return system to history
}
// ---------- константы (без сокета) ----------
/** Все IPv4/IPv6 адреса интерфейсов + 127.0.0.1 — для логов и тестов. */
fun allIps(): List<String> {
val ips = linkedSetOf("127.0.0.1")
try {
for (intf in java.net.NetworkInterface.getNetworkInterfaces()) {
if (!intf.isUp || intf.isLoopback) continue
for (addr in intf.inetAddresses) {
if (addr is java.net.Inet4Address) addr.hostAddress?.let(ips::add)
}
}
} catch (_: Exception) {
}
return ips.toList()
}
private fun completionJson(id: String, created: Long, text: String): String =
"{" +
"\"id\":\"$id\"," +
"\"object\":\"chat.completion\"," +
"\"created\":$created," +
"\"model\":\"$MODEL_NAME\"," +
"\"choices\":[{" +
"\"index\":0," +
"\"message\":{\"role\":\"assistant\",\"content\":\"${jsonEscape(text)}\"}," +
"\"finish_reason\":\"stop\"" +
"}]," +
"\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0}" +
"}"
/** Фреймы SSE-стрима: роль, по слову (слово + следующие пробелы), finish + [DONE]. */
private fun streamFrames(id: String, created: Long, text: String): List<String> {
val frames = mutableListOf<String>()
frames.add(sseChunk(id, created, role = true))
for (m in Regex("""\S+\s*""").findAll(text)) {
frames.add(sseChunk(id, created, delta = m.value))
}
frames.add(sseChunk(id, created, finish = true))
frames.add("data: [DONE]\n\n")
return frames
}
private fun sseChunk(id: String, created: Long, role: Boolean = false, delta: String? = null, finish: Boolean = false): String {
val deltaJson = buildString {
if (role) append("\"role\":\"assistant\"")
if (delta != null) {
if (role) append(',')
append("\"content\":\"${jsonEscape(delta)}\"")
}
}
val finishReason = if (finish) "\"stop\"" else "null"
val jsonBody = "{" +
"\"id\":\"$id\"," +
"\"object\":\"chat.completion.chunk\"," +
"\"created\":$created," +
"\"model\":\"$MODEL_NAME\"," +
"\"choices\":[{" +
"\"index\":0," +
"\"delta\":{$deltaJson}," +
"\"finish_reason\":$finishReason" +
"}]" +
"}"
return "data: $jsonBody\n\n"
}
private fun errorJson(message: String, code: Int): String = buildString {
val type = if (code in 400..499) "invalid_request_error" else "server_error"
append("{\"error\":{\"message\":\"").append(jsonEscape(message)).append("\",")
append("\"type\":\"").append(type).append("\",\"code\":null}}")
}
private fun modelsJson(): String =
"{\"object\":\"list\",\"data\":[{" +
"\"id\":\"$MODEL_NAME\"," +
"\"object\":\"model\"," +
"\"created\":0," +
"\"owned_by\":\"viewmate\"" +
"}]"
private fun helpJson(): String =
"{\"message\":\"Viewmate OpenAI-совместимый сервер\"," +
"\"endpoints\":[{" +
"\"method\":\"POST\",\"path\":\"/v1/chat/completions\"," +
"\"description\":\"Chat completion (stream=true|false)\"," +
"\"model\":\"$MODEL_NAME\"}]," +
"\"models\":[\"$MODEL_NAME\"],\"port\":${PhoneConfig.OPENAI_SERVER_PORT}}"
private fun jsonEscape(s: String): String =
s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
// ---------- DTO запроса ----------
@Serializable
private data class OaiRequest(
val model: String? = null,
val messages: List<OpenAIMessage> = emptyList(),
val stream: Boolean = false,
)
}
@@ -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
@@ -15,21 +17,45 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.log.BatchingLogCollector
import pw.binom.viewmate.core.log.FileLogSpool
import pw.binom.viewmate.core.log.LokiConfig
import pw.binom.viewmate.core.log.SystemClock
import pw.binom.viewmate.core.log.lokiLogSink
import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions 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.AudioSyncInfo
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.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.LlmClient import pw.binom.viewmate.phone.agent.LlmClient
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.PhraseRecognizer
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
import pw.binom.viewmate.phone.stt.SttStreamer import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
/** /**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState, * Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
@@ -39,8 +65,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) }
@@ -59,6 +90,13 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */ /** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) } val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
/** Прогресс просмотра по элементам (filesDir/watchprogress.json): возобновление + индикация в каталоге. */
val watchProgress: WatchProgressStore by lazy { WatchProgressStore(filesDir) }
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
lateinit var logCollector: BatchingLogCollector
private set
/** История общения с ассистентом (SQLite, "assistant.db"). */ /** История общения с ассистентом (SQLite, "assistant.db"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) } val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
@@ -85,76 +123,347 @@ 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-клиент по текущему выбору [llmChoice] для OpenAI-HTTP-сервера
* ([OpenAiHttpServer]) — без тул-цикла ассистента (клиент OpenAI API сам
* управляет контекстом). Локальный клиент переиспользует [localLlm]
* (движок LiteRT один на процесс). null — серверный без ключа или локальная
* модель ещё не скачана.
*/
fun openAiLlm(): LlmClient? = synchronized(assistantLock) {
when (_llmChoice.value) {
LlmModelChoice.REMOTE ->
if (BuildConfig.LLM_API_KEY.isBlank()) null
else RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
LlmModelChoice.LOCAL ->
if (!localModelFile().isFile) null
else localLlm ?: LocalLlmClient(
modelFile = localModelFile(),
cacheDir = File(cacheDir, "litertlm"),
).also { localLlm = it }
}
}
/** Выбор модели 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
/** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */
private var localLlmAutoRetry = false
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.
* Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп»)
* ждёт 60с и снова пытается, пока файла нет.
*/
fun downloadLocalLlmModel() {
val current = localLlmDownloadJob
if (current != null && current.isActive) return
localLlmAutoRetry = true
localLlmDownloadJob = scope.launch {
while (localLlmAutoRetry && !localModelFile().isFile) {
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()} байт")
return@launch
} catch (e: CancellationException) {
_localLlmState.value = LocalLlmModelState.Idle
throw e
} catch (e: Exception) {
val msg = e.message ?: e.toString()
_localLlmState.value = LocalLlmModelState.Error(msg)
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
delay(60_000)
}
}
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
}
}
/** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
fun cancelLocalLlmDownload() {
localLlmAutoRetry = false
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, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
toolkit = tk,
// Нативный тул-канал LiteRT: только локальная модель видит схемы
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
nativeTools = choice == LlmModelChoice.LOCAL,
// Локальной модели нужно время: инициализация движка ~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}»"
}
/** /**
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки. * Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем. * Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
*/ */
private val NO_LLM_ANSWER =
"Серверный LLM не настроен (нет API-ключа в local.properties). " +
"Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " +
"либо задайте llm.apiKey и пересоберите."
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()
val answer: String
if (assistant == null) { if (assistant == null) {
log("assistant", "LLM-ключ не задан — фраза пропущена") // Серверный LLM без API-ключа: не «пропущена в никуда», а явная
continue // подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
answer = NO_LLM_ANSWER
persistNoLlm(phrase)
} else {
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
answer = runCatching {
val active = _activeSessionId.value
if (active != null) {
assistant.processInSession(active, phrase)
} else {
assistant.process(phrase)
}
}
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
} }
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
val answer = runCatching { assistant.process(phrase) }
.onFailure { log("assistant", "ошибка: ${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))
} }
} }
/** /**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models — * Нет работающего ассистента (сервер без ключа): сохраняем и user-фразу, и
* короткую подсказку, чтобы в чате/на очках не было «провала» в истории.
*/
private fun persistNoLlm(phrase: String) {
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: chatDao.createSession("нет LLM")
chatDao.addMessage(id, role = ChatRole.USER, content = phrase)
chatDao.addMessage(id, role = ChatRole.ASSISTANT, content = NO_LLM_ANSWER)
}
/**
* История активной (или последней) сессии для очков: только роли 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)
}
/**
* Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба * кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс. * на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
*/ */
private fun ensureStt(): SttStreamer? { private fun ensureStt(): SttStreamer? {
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 qwenConv = File(modelsDir, "conv_frontend.onnx")
val encoder = File(modelsDir, "small-encoder.int8.onnx") val qwenEnc = File(modelsDir, "encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx") val qwenDec = File(modelsDir, "decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt") val qwenTok = File(modelsDir, "tokenizer")
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) { if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null return null
} }
val whisper = WhisperStt( val stt: PhraseRecognizer = Qwen3AsrStt(
encoderPath = encoder.absolutePath, convFrontendPath = qwenConv.absolutePath,
decoderPath = decoder.absolutePath, encoderPath = qwenEnc.absolutePath,
tokensPath = tokens.absolutePath, decoderPath = qwenDec.absolutePath,
tokenizerDir = qwenTok.absolutePath,
numThreads = 4, numThreads = 4,
hotwords = STT_HOTWORDS,
) )
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw")
} else {
null
}
log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)")
val s = SttStreamer( val s = SttStreamer(
stt = whisper, stt = stt,
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 готов (qwen3_asr)")
return s return s
} }
} }
@@ -189,22 +498,72 @@ 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)
OpenAiHttpServer.start(this)
nsd.publish() nsd.publish()
server.hub.sttFactory = { ensureStt() } server.hub.sttFactory = { ensureStt() }
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
// Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki
// выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool.
logCollector = BatchingLogCollector(
device = "phone",
sink = lokiLogSink(),
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64),
clock = SystemClock(),
flushIntervalMs = 60_000,
maxBatchEntries = 500,
)
logCollector.start(scope)
attachLogCollector(logCollector)
pw.binom.viewmate.core.logCollector = logCollector
server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) }
log(
"server",
"лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " +
"спул: filesDir/logspool)",
)
scope.launch { assistantLoop() } scope.launch { assistantLoop() }
// Автодокачка локальной LLM-модели при старте, если файла нет.
// Каскадный автоповтор (60с) до успеха или «стоп» в настройках.
if (!localModelFile().isFile) {
log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})")
downloadLocalLlmModel()
}
val lastPositionAt = AtomicLong(0) val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false) val lastWasPlaying = AtomicBoolean(false)
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен) // + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex -> // + прогресс просмотра (resume + индикация в каталоге)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs, durationMs ->
lastPositionAt.set(System.currentTimeMillis()) lastPositionAt.set(System.currentTimeMillis())
lastWasPlaying.set(playing) lastWasPlaying.set(playing)
audioSync.sync(positionMs, playing) audioSync.sync(positionMs, playing, sentTs)
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) } scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
if (itemId != null) {
scope.launch(Dispatchers.IO) { watchProgress.upsert(itemId, positionMs, durationMs) }
}
}
// Индикатор аудио-синхронизации в статус-панели очков: |звук − видео| в мс
// на каждый sync-цикл (~5с); active=false (видео на очках не играет) — панель скрывает.
scope.launch {
audioSync.diffMs.collect { diff ->
server.hub.broadcast(
AudioSyncInfo(diffMs = diff.toInt(), active = audioSync.isActive && audioSync.videoPlaying),
)
}
} }
// watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту // watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту
@@ -264,6 +623,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()
@@ -273,5 +633,8 @@ class PhoneApp : Application() {
companion object { companion object {
lateinit var instance: PhoneApp lateinit var instance: PhoneApp
private set private set
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
} }
} }
@@ -12,8 +12,23 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080 const val SERVER_PORT = 8080
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */ /** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */
const val AUDIO_SILENCE_PAUSE_MS = 5_000L const val OPENAI_SERVER_PORT = 8800
/**
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
*/
const val USE_BLUETOOTH = true
/**
* Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит).
* 10 с = два пропущенных пинга: 5-секундные пинги очков джиттят (интервалы 3.8–6.3 с —
* SPP-запись / main-поток очков задерживаются), и порог 5 с ложно ставил звук на паузу
* при каждом позднем пинге (слышимый провал 200–800 мс, «звук возобновился с того же места»).
*/
const val AUDIO_SILENCE_PAUSE_MS = 10_000L
/** Период проверки watchdog. */ /** Период проверки watchdog. */
const val WATCHDOG_TICK_MS = 2_000L const val WATCHDOG_TICK_MS = 2_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,116 @@
package pw.binom.viewmate.phone
import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** Прогресс просмотра элемента: как далеко досмотрели и длительность видео. */
@Serializable
data class WatchProgress(
val positionMs: Long,
/** Длительность видео, мс; -1 = неизвестно (старые очки не сообщали durationMs). */
val durationMs: Long = -1L,
) {
/** Процент просмотренного (0..100); null — длительность неизвестна. */
fun watchedPercent(): Int? =
if (durationMs <= 0L) null else ((positionMs.toDouble() / durationMs) * 100.0).toInt().coerceIn(0, 100)
}
@Serializable
internal data class WatchProgressFile(val entries: Map<String, WatchProgress>)
/**
* Прогресс просмотра по элементам: `filesDir/watchprogress.json`.
*
* Заполняется из PlaybackPosition очков (раз в 5с, только при продвижении вперёд),
* используется в двух местах: (1) возобновление просмотра с того же места по «▶ Смотреть»
* и (2) индикация просмотренного на карточках каталога.
* Ключ — itemId (не зависит от дорожки звука — позиция общая).
* Конструктор принимает директорию (в проде — filesDir) для JVM-тестируемости.
*/
class WatchProgressStore(dir: File) {
companion object {
/** Понять «новое начало»: просмотрено меньше 15 с — возобновляем с начала. */
const val MIN_RESUME_MS = 15_000L
/** 98% и больше — элемент досмотрен: в следующий раз начинаем сначала. */
const val FINISHED_RATIO = 0.98
}
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
private val file = File(dir, "watchprogress.json")
private val lock = Any()
private val entries = LinkedHashMap<String, WatchProgress>()
private val _flow = MutableStateFlow<Map<String, WatchProgress>>(emptyMap())
/** Снимки прогресса по itemId — для карточек каталога. */
val flow: StateFlow<Map<String, WatchProgress>> = _flow.asStateFlow()
init {
load()
_flow.value = entries.toMap()
}
/**
* Записать позицию элемента. На диск пишем только при продвижении вперёд
* или когда впервые известна длительность; дубликаты (пауза) — без записи.
* Позиция дальше длительности — мусор ExoPlayer (гашение экрана): игнор.
*/
fun upsert(itemId: String, positionMs: Long, durationMs: Long = -1L) {
if (positionMs < 0L) return
synchronized(lock) {
val old = entries[itemId] ?: WatchProgress(0L)
val knownDuration = if (durationMs > 0L) durationMs else old.durationMs
if (knownDuration > 0L && positionMs > knownDuration) {
log("progress", "упсерт отклонён: позиция $positionMs мс > длительность $knownDuration мс ($itemId)")
return
}
val advances = positionMs > old.positionMs ||
(durationMs > 0L && durationMs != old.durationMs)
if (!advances) return
entries[itemId] = WatchProgress(positionMs, if (durationMs > 0L) durationMs else old.durationMs)
persist()
_flow.value = entries.toMap()
}
}
/**
* Позиция для возобновления просмотра: null = начинать с начала
* (не смотрели, смотрели меньше [MIN_RESUME_MS] или досмотрели до [FINISHED_RATIO]).
*/
fun resumePosition(itemId: String): Long? {
val e = synchronized(lock) { entries[itemId] } ?: return null
return when {
e.positionMs < MIN_RESUME_MS -> null
e.durationMs > 0L && e.positionMs >= e.durationMs * FINISHED_RATIO -> null
else -> e.positionMs
}
}
/** Процент просмотренного (0..100); null — элемент не смотрели или длительность неизвестна. */
fun watchedPercent(itemId: String): Int? {
val e = synchronized(lock) { entries[itemId] } ?: return null
return e.watchedPercent()
}
private fun load() {
val text = runCatching { file.readText() }.getOrNull() ?: return
val parsed = runCatching { json.decodeFromString(WatchProgressFile.serializer(), text).entries }.getOrNull()
if (parsed != null) {
entries.putAll(parsed)
} else {
log("progress", "watchprogress.json не прочитан — начинаю пустым")
}
}
private fun persist() {
val result = runCatching { json.encodeToString(WatchProgressFile.serializer(), WatchProgressFile(entries.toMap())) }
result.onSuccess { text ->
runCatching { file.writeText(text) }.onFailure { log("progress", "запись watchprogress.json: ${it.message}") }
}.onFailure { log("progress", "сериализация watchprogress.json: ${it.message}") }
}
}
@@ -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,277 @@
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>()
// ---------- сборка промпта ----------
/**
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
*
* [nativeTools] — нативный тул-канал (локальная модель, LiteRT): тулы
* модель видит через JSON-схемы в движке, строку текстового протокола
* TOOL_CALL в промпт НЕ кладем (иначе 2B-модель путает каналы).
*/
fun systemPrompt(sessionId: Long, nativeTools: Boolean = false): String {
val note = pendingNotes.remove(sessionId)
val activeSets = toolsets.activeNames(sessionId)
val sb = StringBuilder()
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
if (nativeTools) {
sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
.append("После получения результата тула ответь пользователю текстом.")
} else {
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()
}
/**
* Нейтральные схемы тул для [LlmClient.chatWithTools] (нативный канал):
* ядро (5 тул) + тулы АКТИВНЫХ тулсетов (с полными именами `набор_тул`).
* Неактивные тулсеты сюда не попадают — модель увидит их только после
* enable_toolset (в следующем callLlm схема пересобирается).
*/
fun toolSpecs(sessionId: Long): List<ToolSpec> {
val out = mutableListOf(
ToolSpec(TOOL_TIME, "Текущие дата, время и часовой пояс (ISO 8601)"),
ToolSpec(TOOL_SHOW_TEXT, "Показать текст на очках", listOf("text")),
ToolSpec(TOOL_READ_SKILL, "Прочитать полное содержимое скилла по имени", listOf("name")),
ToolSpec(TOOL_ENABLE, "Активировать тулсет (живёт 10 мин от последнего вызова его тул)", listOf("name")),
ToolSpec(TOOL_DISABLE, "Деактивировать тулсет", listOf("name")),
)
for (setName in toolsets.activeNames(sessionId)) {
val ts = toolsets.toolset(setName) ?: continue
ts.tools.forEach { s -> out += ToolSpec(ts.toolFull(s), s.description, s.args) }
}
return out
}
// ---------- парсинг аргументов ----------
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
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` (или «голое» значение для
* одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
* (общая с RemoteLlmClient), метод оставлен для API/тестов.
*/
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
parseToolCallArgs(raw, expected)
// ---------- диспач вызовов ----------
/**
* Результат диспача:
* [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,144 @@ import kotlinx.coroutines.withTimeoutOrNull
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM, * LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
* история накапливается в сессии. Синхронный вызов из одной корутины * история накапливается в сессии. Синхронный вызов из одной корутины
* (сериализация фраз — LLM-вызовы не пересекаются). * (сериализация фраз — LLM-вызовы не пересекаются).
*
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
* Ассистент выполняет тул и возвращает результат как 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>, tools: List<ToolSpec>, system: String?) -> LlmResult,
private val toolkit: AgentToolkit? = null,
private val tools: List<AgentTool> = AgentTools.ALL,
private val nativeTools: Boolean = false,
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 result = callLlm(history, sessionId)
?: return "Ассистент не ответил (таймаут)" var lastToolResult: String? = null
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) var visibleCalls = 0
chatDao.touchSession(sessionId) var llmCalls = 1
return answer // Цикл тулов: до [MAX_TOOL_CALLS] видимых вызовов подряд (защита от
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
while (true) {
val call = result.toolCalls.firstOrNull()
?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
val toolName = call.name
if (toolkit == null) {
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
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
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++
continue
}
when (val dispatch = toolkit.dispatch(sessionId, toolName, call.arguments)) {
is AgentToolkit.Dispatch.Run -> {
lastToolResult = dispatch.result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
logTool(sessionId, toolName, dispatch.result)
if (++visibleCalls >= MAX_TOOL_CALLS) break
result = 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
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
}
}
}
// LLM зациклилась на тул-вызовах — отдаём последний результат тула как
// ответ, чтобы пользователь получил данные, а не сырой маркер.
val stillTool = result.toolCalls.firstOrNull()
return if (stillTool != null && lastToolResult != null) {
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
} else {
result.text
}
}
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
val specs = if (toolkit != null) {
toolkit.toolSpecs(sessionId)
} else {
tools.map { ToolSpec(name = it.name, description = it.description) }
}
val result = withTimeoutOrNull(timeoutMs) { chat(history, specs, system) }
?: return LlmResult("Ассистент не ответил (таймаут)")
if (result.toolCalls.isEmpty()) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
chatDao.touchSession(sessionId)
}
return result
}
/** Системный промпт (наследственный путь, без [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
/** Случайное имя сессии (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,251 @@
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 pw.binom.viewmate.phone.log
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
/**
* Чат с тул-каналом: [tools] — схемы доступных тул ([ToolSpec]).
* Локальный клиент регистрирует их в движке (нативный LiteRT-канал),
* удалённый игнорирует список (тулы описывает текстовый протокол в промпте).
* Результат [LlmResult] несёт И текст, И структурированные вызовы
* [LlmToolCall] — клиент нормализует источник (нативный / текстовый
* маркер), [Assistant] потребляет оба бэкенда одинаково.
*/
suspend fun chatWithTools(
messages: List<ChatMessage>,
tools: List<ToolSpec> = emptyList(),
system: String? = null,
): LlmResult = LlmResult(text = chat(messages, system))
}
/**
* Нейтральная схема тула для [LlmClient.chatWithTools]: имя, описание и
* аргументы (та же нотация, что у [ToolSignature]: «query», «seek_ms?» —
* «?» = опциональный).
*/
data class ToolSpec(
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 LlmToolCall(
val name: String,
val arguments: Map<String, String> = emptyMap(),
)
/** Ответ [LlmClient.chatWithTools]: текст + декларированные вызовы тул (могут быть пусты). */
data class LlmResult(
val text: String,
val toolCalls: List<LlmToolCall> = emptyList(),
)
// ---------- Текстовый протокол вызова тула (RemoteLlmClient) ----------
/**
* Строка вызова тула в текстовом протоколе: `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
*/
val TOOL_CALL_RE =
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
/**
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
* [expected] — имена аргументов схемы ([ToolSpec.argNames]).
*/
fun parseToolCallArgs(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(Regex("[,&]"))) {
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
}
/**
* Лог длинного текста (тела запроса, промпта, ответа) в канал [log]
* кусками ≤3500 символов — лимит записи logcat ~4096, длиннее обрезалось бы.
*/
internal fun logChunked(tag: String, label: String, text: String) {
if (text.length <= 3500) {
log(tag, "$label: $text")
return
}
val size = 3500
val total = (text.length + size - 1) / size
var from = 0
var i = 0
while (from < text.length && i < total) {
val to = minOf(from + size, text.length)
log(tag, "$label [${i + 1}/$total]: ${text.substring(from, to)}")
from = to
i++
}
}
/**
* 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) {
} when (msg.role) {
for (msg in messages) { ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
when (msg.role) { ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content) ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content) ChatRole.TOOL -> if (msg.content.isNotBlank()) {
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content) dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
// нужен 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))
log("llm", "remote: запрос: модель=$model, сообщений=${dto.size}")
logChunked("llm", "remote: тело запроса", body)
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 reply = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull()
val text = reply?.trim() ?: ""
logChunked("llm", "remote: ответ", text.ifEmpty { "(пусто)" })
text
}
}
}
/**
* Текстовый протокол: тулы описываются в системном промпте, модель отвечает
* строкой `TOOL_CALL: имя|арг1=знач1` — маркер разбираем здесь и
* нормализуем в [LlmToolCall] (схема аргументов — из [tools], «голое»
* значение прилетает первому обязательному аргументу).
*/
override suspend fun chatWithTools(
messages: List<ChatMessage>,
tools: List<ToolSpec>,
system: String?,
): LlmResult {
val text = chat(messages, system)
val m = TOOL_CALL_RE.find(text) ?: return LlmResult(text)
val name = m.groupValues[1].lowercase()
val argsRaw = m.groupValues[2].trim().ifBlank { null }
val expected = tools.firstOrNull { it.name == name }?.argNames() ?: emptyList()
val args = parseToolCallArgs(argsRaw, expected)
log("llm", "remote: toolCall: $name (сырое='${argsRaw.orEmpty()}') → $args")
return LlmResult(text, listOf(LlmToolCall(name, args)))
} }
} }
@@ -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,503 @@
package pw.binom.viewmate.phone.agent
import pw.binom.litert.LiteBackend
import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteExperimental
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteTool
import pw.binom.litert.LiteToolCall
import pw.binom.litert.google.googleLiteLlm
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
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.
* Экспериментальный флаг: без enableSpeculativeDecoding статический
* TopK-сэмплер на GPU требует OpenCL-драйвер, которого нет в Android
* app-namespace («Can not find OpenCL library»); на HONOR работает
* WebGPU-сэмплер, т.е. флаг включён.
*/
fun liteConfig(modelFile: File, cacheDir: File): LiteConfig = LiteConfig(
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 = LiteBackend.GPU,
visionBackend = LiteBackend.GPU,
audioBackend = LiteBackend.CPU,
cacheDir = cacheDir.absolutePath,
experimental = LiteExperimental(enableSpeculativeDecoding = true),
)
}
// ---------- Чистые вспомогательные (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<LiteMessage>,
val incoming: LiteMessage,
)
/**
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
* - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model];
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (консервативно: общая
* с remote-клиентом текстовая конвенция; нативная роль LiteRole.TOOL —
* задел на будущее, когда шаблон модели будет уметь родные тулзы);
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
* - пустые content отбрасываются.
*
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL.
* В RAW-режиме (контракт pw.binom.litert) владелец истории — потребитель:
* каждый инференс — новый полный промпт, движок не несёт состояния между
* вызовами; будущие механизмы чистки/сжатия истории для движка — просто
* новый (короткий) полный промпт.
* Если последние сообщения только модельные/история пуста — 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<LiteMessage>()
for (msg in messages) {
val text = msg.content
when (msg.role) {
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
ChatRole.USER -> if (text.isNotBlank()) mapped += LiteMessage.user(text)
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += LiteMessage.model(text)
ChatRole.TOOL -> if (text.isNotBlank()) {
mapped += LiteMessage.user("Результат тула ${msg.toolCallId ?: "?"}: $text")
}
}
}
val last = messages.lastOrNull { it.content.isNotBlank() }
val incoming: LiteMessage
val initial: List<LiteMessage>
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
initial = mapped.dropLast(1)
incoming = mapped.last()
} else {
initial = mapped
incoming = LiteMessage.user("Продолжай.")
}
return LocalLlmPrompt(
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
initialMessages = initial,
incoming = incoming,
)
}
/**
* OpenAPI-JSON-дескриптор [ToolSpec] для LiteRT-LM: ПЛОСКИЙ объект
* `{"name","description","parameters"}` — так его читает движок
* (litertlm 0.16.1, ToolKt.tool: `json.get("name").getAsString()` на
* верхнем уровне; OpenAI-обёртка `{"type":"function","function":{…}}`
* падает ToolException «Failed to parse field name»). Движок передаёт
* строку модели КАК ЕСТЬ — без репарсинга и валидации (контракт тул-слоя
* pw.binom.litert, tag 5), поэтому JSON строим сами.
* Все аргументы у нас строковые (текстовый протокол тулкита), `type` — «string».
*/
fun toolJson(spec: ToolSpec): String = buildJsonObject {
put("name", spec.name)
put("description", spec.description)
putJsonObject("parameters") {
put("type", "object")
if (spec.argNames().isNotEmpty()) {
putJsonObject("properties") {
spec.args.forEach { a ->
putJsonObject(a.substringBefore('?')) {
put("type", "string")
}
}
}
val required = spec.requiredArgs()
if (required.isNotEmpty()) {
putJsonArray("required") {
required.forEach { add(JsonPrimitive(it)) }
}
}
}
}
}.toString()
/**
* Наш [ToolSpec] в виде [LiteTool] LiteRT-слоя: `describe()` — самособранное
* OpenAPI-JSON (плоский, для LiteRT-LM) из [toolJson]. `invoke()` недостижим: в ручном режиме
* (automaticToolCalling = false) тул-цикл ведёт [Assistant], движок не
* исполняет тулы — если движок всё же позвал `invoke`, это баг движка,
* падаем с понятным сообщением.
*/
private class SpecLiteTool(spec: ToolSpec) : LiteTool {
private val descriptor: String = toolJson(spec)
override fun describe(): String = descriptor
override fun invoke(arguments: String): String =
error("LiteTool.invoke: тул-цикл ведёт Assistant (automaticToolCalling = false), движок не должен исполнять тулы")
}
// ---------- Скачивание модели ----------
/** Состояние локальной модели для 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),
* через KMP-библиотеку pw.binom.litert (универсальный контракт
* RAW-инференса: владелец истории — потребитель).
*
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
* новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
* (системная инструкция + вся история + входящее) и схемы тул,
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
* пользователю, падение приложения исключено.
*
* Модель-файл: [modelFile] (getExternalFilesDir(null)); [cacheDir] —
* кэш-каталог движка (cacheDir, не tmpDir — tmp-файлы удаляются системой).
*/
class LocalLlmClient(
private val modelFile: File,
private val cacheDir: File,
) : LlmClient {
private val llmLock = Any()
@Volatile
private var llm: LiteLlm? = null
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
chatWithTools(messages, emptyList(), system).text
/**
* Чат с тул-каналом: [tools] регистрируются в движке нативным LiteRT-каналом
* (JSON-схемы [toolJson] → модели; вызовы → [LlmResult.toolCalls]).
* Ручной режим: движок тулы НЕ исполняет (automaticToolCalling = false) —
* цикл «вызов → dispatch → результат в историю → повторный инференс»
* ведёт [Assistant]; каждый круг — новый RAW-инференс.
*/
override suspend fun chatWithTools(
messages: List<ChatMessage>,
tools: List<ToolSpec>,
system: String?,
): LlmResult {
log("llm", "chat: вызов (messages=${messages.size}, tools=${tools.size}, system=${system?.length ?: 0} симв)")
val llm = ensureLlm()
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)}")
// Полный контент, уходящий в модель: системный промпт, история, входящее, схемы тул
logChunked("llm", "local: system", prompt.systemInstruction ?: "(нет)")
prompt.initialMessages.forEachIndexed { i, m ->
logChunked("llm", "local: msg[$i/${prompt.initialMessages.size}] ${m.role}", m.text)
}
logChunked("llm", "local: incoming", prompt.incoming.text)
tools.forEachIndexed { i, spec ->
log("llm", "local: tool[$i/${tools.size}] ${spec.name}: ${toolJson(spec)}")
}
val config = LiteConversationConfig(
systemInstruction = prompt.systemInstruction,
// RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
initialMessages = prompt.initialMessages + prompt.incoming,
tools = tools.map { SpecLiteTool(it) },
// Ручной режим: исполняют тулы не движок, а Assistant (см. KDoc метода)
automaticToolCalling = false,
)
var text = ""
var toolCalls = emptyList<LiteToolCall>()
var chunks = 0
var tokens = 0
val t0 = System.currentTimeMillis()
var firstTokenAt = 0L
var conversation: LiteConversation? = null
try {
withTimeout(120_000) {
conversation = llm.createConversation(config)
// Поток либы сам себя не завершает (callbackFlow живёт до отмены):
// остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
conversation.sendStream(prompt.incoming.text)
.onEach { delta ->
chunks++
val chunkText = delta.text
text += chunkText
if (chunkText.isNotEmpty()) {
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
tokens += 1
}
if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls
if (chunks <= 3 || chunks % 20 == 0) {
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}${if (delta.toolCalls.isNotEmpty()) " [тулы: ${delta.toolCalls.map { it.name }}]" else ""}")
}
}
.takeWhile { !it.isDone }
.collect { }
}
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)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
val normalized = toolCalls.map { call ->
LlmToolCall(
name = call.name,
arguments = call.arguments.mapValues { entry -> entry.value?.toString() ?: "" },
)
}
logChunked("llm", "local: ответ", text.trim().ifEmpty { "(пусто)" })
normalized.forEach { call ->
log("llm", "local: toolCall: ${call.name} аргументы=${call.arguments}")
}
return LlmResult(text.trim(), normalized)
} catch (e: CancellationException) {
log("llm", "chat: отменено")
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 {
// либа (RAW-путь) закрывает беседу сама, здесь — явный close для
// прямого createConversation: каждый вызов — своя беседа
runCatching { conversation?.close() }
}
}
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
fun close() {
val local = synchronized(llmLock) {
val l = llm
llm = null
l
}
runCatching { local?.close() }
}
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
private fun ensureLlm(): LiteLlm {
val cached = llm
if (cached != null) return cached
return synchronized(llmLock) {
val existing = llm
if (existing != null) return existing
if (!modelFile.isFile) {
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
}
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
}
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
// скомпилированные шейдеры в cacheDir; если каталога нет — движок
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
// в кэш не пишет, поэтому работал и без mkdirs.
cacheDir.mkdirs()
// Экспериментальные флаги (speculative decoding → WebGPU-сэмплер)
// ушли в LiteConfig (см. [Gemma4E2B.liteConfig]).
val created = googleLiteLlm(Gemma4E2B.liteConfig(modelFile, cacheDir))
log("llm", "локальный движок готов: ${modelFile.name}")
llm = 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,29 @@
package pw.binom.viewmate.phone.stt
import pw.binom.asr.qwen3.Qwen3Config
import pw.binom.asr.qwen3.createQwen3Sherpa
/** Офлайн-распознавание: asr-qwen3 (sherpa-onnx + Qwen3-ASR-0.6B int8, CPU). */
class Qwen3AsrStt(
convFrontendPath: String,
encoderPath: String,
decoderPath: String,
tokenizerDir: String,
numThreads: Int = 4,
hotwords: String = "",
) : PhraseRecognizer {
/** Тяжёлая нативка создаётся один раз; recognize() — single-shot (стрим на вызов). */
private val recognizer = createQwen3Sherpa(
Qwen3Config(
convFrontendPath = convFrontendPath,
encoderPath = encoderPath,
decoderPath = decoderPath,
tokenizerDir = tokenizerDir,
numThreads = numThreads,
hotwords = hotwords,
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000)
}
@@ -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,15 +1,12 @@
package pw.binom.viewmate.phone.stt package pw.binom.viewmate.phone.stt
import android.content.Context import android.content.Context
import android.util.Log
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */ /** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
object SttDebug { object SttDebug {
private const val TAG = "stt"
/** /**
* Распознаёт [wavPath] по фразам (VAD + Whisper). * Распознаёт [wavPath] по фразам (VAD + Whisper).
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null. * Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
@@ -23,16 +20,24 @@ object SttDebug {
// через: adb push /data/local/tmp/... + run-as pkg cp // через: adb push /data/local/tmp/... + run-as pkg cp
val modelsDir = File(context.filesDir, "models") val modelsDir = File(context.filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath val convFrontend = File(modelsDir, "conv_frontend.onnx").absolutePath
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath val encoder = File(modelsDir, "encoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "small-tokens.txt").absolutePath val decoder = File(modelsDir, "decoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "tokenizer").absolutePath
log("[stt] создаём VAD...") log("[stt] создаём VAD...")
val segments = VadSegmenter.vadSegments(wav.samples, vadModel) val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
log("[stt] фраз (VAD): ${segments.size}") log("[stt] фраз (VAD): ${segments.size}")
log("[stt] создаём Whisper-small (int8, CPU)...") log("[stt] создаём Qwen3-ASR-0.6B (int8, CPU)...")
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4) val stt = Qwen3AsrStt(
convFrontendPath = convFrontend,
encoderPath = encoder,
decoderPath = decoder,
tokenizerDir = tokens,
numThreads = 4,
hotwords = "тулсет,тулы,умеешь",
)
val parts = ArrayList<String>() val parts = ArrayList<String>()
for ((i, seg) in segments.withIndex()) { for ((i, seg) in segments.withIndex()) {
val startMs = seg.first / 16 val startMs = seg.first / 16
@@ -46,14 +51,14 @@ object SttDebug {
log("[stt] ВЕСЬ ТЕКСТ: $all") log("[stt] ВЕСЬ ТЕКСТ: $all")
all all
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "[stt] ошибка: ${t.message}", t) log("[stt] ошибка: ${t.message}")
t.printStackTrace() t.printStackTrace()
null null
} }
} }
private fun log(msg: String) { private fun log(msg: String) {
Log.i(TAG, msg) pw.binom.viewmate.phone.log("stt", msg)
println(msg) println(msg)
} }
} }
@@ -1,53 +1,47 @@
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 pw.binom.viewmate.core.stt.s16leToFloat
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */ import pw.binom.viewmate.core.stt.splitPhrases
internal fun s16leToFloat(pcm: ByteArray): FloatArray { import pw.binom.viewmate.core.stt.trimEdges
val n = pcm.size / 2 import pw.binom.viewmate.phone.log
val out = FloatArray(n)
var i = 0
for (j in 0 until n) {
val lo = pcm[i].toInt() and 0xff
val hi = pcm[i + 1].toInt() and 0xff
i += 2
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
out[j] = (s / 32768f).coerceIn(-1f, 1f)
}
return out
}
/** /**
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) → * Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
* Whisper (в отдельном потоке) → колбэк [onPhrase]. * распознаётся только ПОЛНЫЙ буфер сессии в [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 +58,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).
deadlineMs = 0 val expired = lock.withLock {
resetLocked() if (deadlineMs != deadline) return@withLock false
onSilence30s() deadlineMs = 0
} true
}
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 +103,92 @@ 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 out = dumpOut
val need = WINDOW_SIZE - pending.size if (out != null) {
val take = minOf(need, floats.size) runCatching { out.write(pcm) }
val combined = pending + floats.copyOfRange(0, take) .onFailure { log("stt", "PCM-дампа: ${it.message}") }
i += take
if (combined.size == WINDOW_SIZE) {
feedWindow(combined)
pending = FloatArray(0)
} else {
pending = combined
return
}
}
while (i + WINDOW_SIZE <= floats.size) {
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE))
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], затем [splitPhrases] режет буфер на фразы по паузам — каждая
* фраза распознаётся отдельно (whisper принимает волны только ≤ 30 с),
* непустые результаты склеиваются через пробел и перезаписывают [full].
* Все фразы мусорные → [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 phrases = splitPhrases(chunk)
if (phrases.isEmpty()) return
val sessionId = session
val sizes = phrases.map { "%.1fс".format(it.size / SAMPLE_RATE.toFloat()) }.joinToString(", ")
val task = executor.submit {
if (sessionId != session) return@submit
runCatching {
val parts = ArrayList<String>()
for (ph in phrases) {
val text = stt.recognize(ph).trim()
if (text.isNotBlank()) parts.add(text)
}
if (parts.isNotEmpty()) full = parts.joinToString(" ")
parts
}.onSuccess { parts ->
if (parts.isEmpty()) {
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) — без текста (мусор, full не меняю)")
} else {
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) → '$full'")
}
}.onFailure {
log("stt", "полный буфер: ошибка распознавания: ${it.message}")
}
}
lastFuture = task
runCatching { task.get(60, 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",
)
} }
@@ -1,46 +0,0 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig
/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */
class WhisperStt(
private val encoderPath: String,
private val decoderPath: String,
private val tokensPath: String,
private val numThreads: Int = 4,
private val language: String = "ru",
) {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
whisper = OfflineWhisperModelConfig(
encoder = encoderPath,
decoder = decoderPath,
language = language,
task = "transcribe",
tailPaddings = -1,
),
tokens = tokensPath,
numThreads = numThreads,
provider = "cpu",
modelType = "whisper",
),
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}
@@ -0,0 +1,211 @@
package pw.binom.viewmate.phone.ui
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.protocol.DownloadPhase as GlassesDownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress as GlassesDownloadProgress
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.DownloadPhase as PhoneDownloadPhase
import pw.binom.viewmate.phone.DownloadProgress as PhoneDownloadProgress
/** Статусы зеркала, которые считаются «конвертируется прямо сейчас». */
private val CONVERTING_STATUSES = setOf("new", "queued", "processing")
/** Как часто опрашиваем mirror.list, пока открыт экран (конвертация не шлёт события сама). */
private const val REFRESH_MS = 15_000L
/** Строка активности для единого списка в LazyColumn. */
private sealed interface ActivityRow {
val key: String
data class Header(val text: String, override val key: String) : ActivityRow
data class Convert(val itemId: String, val title: String, val statusLabel: String, override val key: String) : ActivityRow
data class Download(val itemId: String, val title: String, val kind: String, val percent: Int, override val key: String) : ActivityRow
}
/**
* Экран «Активность»: всё, что сейчас конвертируется (зеркала) и скачивается
* (звук → телефон, видео → очки). Уровень 1: без серверной доработки — по
* конвертации спиннер (progress на сервере не живой), по скачиванию живой %.
*/
@Composable
fun ActivityScreen() {
val app = PhoneApp.instance
val userId by app.userId.collectAsState()
// Конвертирующиеся зеркала: опрос mirror.list раз в REFRESH_MS (нет push от сервера).
var converting by remember { mutableStateOf<List<MirrorJob>>(emptyList()) }
LaunchedEffect(Unit) {
while (isActive) {
converting = runCatching {
app.mirror.list(limit = 500).filter { it.status in CONVERTING_STATUSES }
}.getOrDefault(converting)
delay(REFRESH_MS)
}
}
// Живой прогресс скачивания: звук (телефон) — DownloadManager, видео (очки) — хаб.
val phoneDownloads by app.downloadManager.downloads.collectAsState()
val activePhone by app.downloadManager.activeIds.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
// Настройки/названия предметов по активным itemId (подтягиваем только недостающие).
val activeIds = remember(converting, activePhone, glassesDownloads) {
(converting.mapNotNull { it.itemId } + activePhone + glassesDownloads.keys.filter { it.isNotBlank() })
.distinct()
}
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(activeIds, userId) {
if (userId.isBlank()) return@LaunchedEffect
activeIds.forEach { id ->
if (titles.containsKey(id)) return@forEach
val name = runCatching { app.jellyfin.item(userId, id)?.Name }.getOrNull()
titles = titles + (id to (name?.takeIf { it.isNotBlank() } ?: id))
}
}
val rows = buildActivityRows(
converting = converting,
phone = phoneDownloads,
activePhone = activePhone,
glasses = glassesDownloads,
titles = titles,
)
if (rows.isEmpty()) {
ScreenPlaceholder("Ничего не конвертируется и не скачивается")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
) {
items(rows) { row ->
ActivityRowView(row)
}
}
}
}
private fun buildActivityRows(
converting: List<MirrorJob>,
phone: Map<String, PhoneDownloadProgress>,
activePhone: Set<String>,
glasses: Map<String, Map<String, GlassesDownloadProgress>>,
titles: Map<String, String>,
): List<ActivityRow> {
val out = ArrayList<ActivityRow>()
val name = { id: String -> titles[id] ?: id }
val liveConverting = converting.filter { !it.itemId.isNullOrBlank() }
if (liveConverting.isNotEmpty()) {
out += ActivityRow.Header("Конвертируется", "h-conv")
liveConverting.forEach { job ->
val id = job.itemId.orEmpty()
val label = when (job.status) {
"processing" -> "обрабатывается"
"queued" -> "в очереди"
"new" -> "взято в работу"
else -> job.status ?: "…"
}
out += ActivityRow.Convert(id, name(id), label, "conv-${job.id}")
}
}
val phoneActive = phone.filterKeys { activePhone.contains(it) }
val glassesActive = glasses.filter { (itemId, files) ->
itemId.isNotBlank() && files.values.any { it.phase == GlassesDownloadPhase.DOWNLOADING }
}
if (phoneActive.isNotEmpty() || glassesActive.isNotEmpty()) {
out += ActivityRow.Header("Скачивается", "h-dl")
phoneActive.forEach { (id, p) ->
out += ActivityRow.Download(id, name(id), "звук", p.percent, "dl-$id-phone")
}
glassesActive.forEach { (id, files) ->
val video = files["video.mkv"] ?: files.values.firstOrNull()
out += ActivityRow.Download(id, name(id), "видео", video?.percent ?: 0, "dl-$id-glasses")
}
}
return out
}
@Composable
private fun ActivityRowView(row: ActivityRow) {
when (row) {
is ActivityRow.Header -> SectionLabel(
text = row.text,
modifier = Modifier.padding(top = 10.dp),
)
is ActivityRow.Convert -> StatusBlock {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
color = PhoneAccent,
strokeWidth = 2.dp,
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.title,
color = PhoneTextDim,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "зеркало: ${row.statusLabel}",
color = PhoneMuted,
fontSize = 10.sp,
)
}
}
}
is ActivityRow.Download -> StatusBlock {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = row.title,
modifier = Modifier.weight(1f),
color = PhoneTextDim,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${row.kind} ${row.percent}%",
color = PhoneMuted,
fontSize = 11.sp,
)
}
Spacer(Modifier.height(6.dp))
ProgressBar(percent = row.percent)
}
}
}
}
@@ -0,0 +1,211 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.PhoneConfig
import pw.binom.viewmate.phone.S3Presigner
import pw.binom.viewmate.phone.log
/** Статусы зеркала, при которых конвертацию заказывать не надо. */
private val MIRROR_BUSY_OR_DONE = setOf("done", "queued", "processing")
/**
* Массовые действия страницы сезона/сериала: все эпизоды отправить на конвертацию,
* скачать всё (звук → телефон, видео → очки) и удалить локальные файлы (телефон + очки).
*
* [episodes] — целевой набор эпизодов, [mirrors] — свежие статусы зеркал,
* [label] — «сезон»/«сериал» для подписи, [contextName] — название для диалога удаления.
*/
@Composable
fun BulkActionsRow(
episodes: List<JellyfinItem>,
mirrors: Map<String, MirrorJob>,
label: String,
contextName: String,
onMessage: (String) -> Unit,
onStateRefresh: () -> Unit,
) {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
val dm = app.downloadManager
val phoneDownloaded by dm.downloadedIds.collectAsState()
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
var busy by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) }
val statusOf: (JellyfinItem) -> String? = { mirrors[it.Id]?.status }
val needConversion = episodes.filter { statusOf(it) !in MIRROR_BUSY_OR_DONE }
val readyToDownload = episodes.filter { statusOf(it) == "done" }
/** Если всё уже конвертировано — кнопку «конвертировать» не рисуем. */
val convertable = needConversion.isNotEmpty()
/** Готовые зеркала, которые ещё не скачаны (телефон/очки); если все скачано — кнопку не рисуем. */
val toDownload = readyToDownload.filterNot { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
val withLocal = episodes.count { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
/** Массовая конвертация: заказ по каждому эпизоду без активного/готового зеркала. */
fun onConvert() {
if (needConversion.isEmpty() || busy) return
busy = true
scope.launch {
var ok = 0
var fail = 0
for (ep in needConversion) {
runCatching { app.mirror.createMirror(ep.Id) }
.onSuccess { ok++ }
.onFailure {
fail++
log("bulk", "mirror ${ep.Id}: ${it.message}")
}
}
busy = false
onStateRefresh()
onMessage(
buildString {
append("Конвертация: заказано $ok")
if (fail > 0) append(", ошибок $fail")
append(" ($label)")
},
)
}
}
/**
* Массовое скачивание готовых зеркал: звук на телефон (DownloadManager),
* видео на очки (DownloadFiles через actions). Очки не подключены — только звук на телефон.
*/
fun onDownload() {
if (toDownload.isEmpty() || busy) return
busy = true
scope.launch {
val actions = app.actions
val glassesOk = app.server.hub.connected.value > 0 && actions != null
var videoSent = 0
for (ep in toDownload) {
dm.startDownload(ep.Id)
if (glassesOk) {
val result = runCatching {
actions.downloadToGlasses(ep.Id, sizes = dm.knownFileSizes(ep.Id)) {
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
}
if (result.isFailure) log("bulk", "скачивание на очки ${ep.Id}: ${result.exceptionOrNull()?.message}")
else videoSent++
}
}
busy = false
onStateRefresh()
onMessage(
buildString {
append("Скачивание: звук → телефон, видео → очки ($videoSent) ($label)")
if (!glassesOk) append(" [очки не подключены — только телефон]")
},
)
}
}
/** Удаление локальных файлов всех эпизодов: диск телефона + каталог контента очков. */
fun onDelete() {
if (withLocal == 0 || busy) return
busy = true
scope.launch {
var deleted = 0
for (ep in episodes) {
if (ep.Id !in phoneDownloaded && ep.Id !in glassesDownloadedIds) continue
dm.delete(ep.Id)
app.actions?.deleteDownloaded(ep.Id)
deleted++
}
busy = false
onStateRefresh()
onMessage("Удалено локальное: $deleted эпизод(ов) ($label)")
}
}
if (convertable || toDownload.isNotEmpty() || withLocal > 0) {
Text(
text = "все эпизоды $label",
color = PhoneMuted,
fontSize = 10.sp,
modifier = Modifier.padding(start = 14.dp, top = 4.dp, end = 14.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (convertable) {
OutlinedButton(onClick = ::onConvert, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "конвертировать",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (toDownload.isNotEmpty()) {
OutlinedButton(onClick = ::onDownload, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "скачать",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (withLocal > 0) {
OutlinedButton(onClick = { confirmDelete = true }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "удалить локальное ($withLocal)",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Удалить локальные файлы?") },
text = {
Text(
"«$contextName»: с телефонов и очков удалятся уже скачанные " +
"видео/звук по ${withLocal} эпизод(ам). Это действие необратимо.",
fontSize = 12.sp,
)
},
confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Удалить") }
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
},
)
}
}
@@ -1,5 +1,6 @@
package pw.binom.viewmate.phone.ui package pw.binom.viewmate.phone.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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
@@ -16,7 +18,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,6 +31,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -43,7 +49,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.WatchProgress
import pw.binom.viewmate.phone.log
/** Вкладки каталога: Фильмы / Сериалы / Папки. */ /** Вкладки каталога: Фильмы / Сериалы / Папки. */
enum class CatalogTab { MOVIES, SERIES, FOLDERS } enum class CatalogTab { MOVIES, SERIES, FOLDERS }
@@ -83,6 +92,8 @@ class CatalogState(
private set private set
var scrollOffset by mutableStateOf(0) var scrollOffset by mutableStateOf(0)
private set private set
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
var onlyDownloaded by mutableStateOf(false)
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */ /** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>() private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
@@ -195,7 +206,6 @@ class CatalogState(
} }
} }
items = loaded items = loaded
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -204,6 +214,15 @@ class CatalogState(
} finally { } finally {
loading = false loading = false
} }
// Зеркала — отдельный «мягкий» запрос: сбой/таймаут mirror.list не чистит
// уже загруженный каталог, просто временно без бейджей.
try {
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("catalog", "зеркала: не удалось (${e.message}) — каталог без бейджей")
}
} }
} }
@@ -230,6 +249,36 @@ fun CatalogScreen(
) { ) {
val app = PhoneApp.instance val app = PhoneApp.instance
val userId by app.userId.collectAsState() val userId by app.userId.collectAsState()
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
// Системная кнопка «назад»: выход из вложенного пути (сериал→сезон, папки) на уровень выше
BackHandler(enabled = state.canGoBack()) {
state.back()
}
// Все эпизоды сериала (для bulk-операций): по каждому сезону — список эпизодов.
var seriesEpisodes by remember { mutableStateOf<List<JellyfinItem>?>(null) }
val seasonIds = state.items.map { it.Id }
LaunchedEffect(state.path, seasonIds, userId) {
if (state.path !is CatalogState.Path.Series || userId.isBlank() || state.items.isEmpty()) {
seriesEpisodes = emptyList()
return@LaunchedEffect
}
seriesEpisodes = state.items.flatMap { season ->
runCatching { app.jellyfin.items(userId, season.Id, listOf("Episode")) }.getOrDefault(emptyList())
}
}
val bulkEpisodes = when (state.path) {
is CatalogState.Path.Season -> state.items
is CatalogState.Path.Series -> seriesEpisodes ?: emptyList()
else -> emptyList()
}
val bulkLabel = when (state.path) {
is CatalogState.Path.Season -> "сезона"
is CatalogState.Path.Series -> "сериала"
else -> ""
}
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями. // Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
@@ -247,6 +296,12 @@ fun CatalogScreen(
} }
val downloaded by app.state.downloadedFlow.collectAsState() val downloaded by app.state.downloadedFlow.collectAsState()
val localDownloaded by app.downloadManager.downloadedIds.collectAsState() val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
// Статус видео на очках: скачанные itemId (свежий GlassesStatus) + живой прогресс
// DownloadProgress (видео очки качают сами, звук — телефон).
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
// «Просмотрено» по элементам (watchprogress.json): тонкий бар на плашке + %/время у названия
val watched by app.watchProgress.flow.collectAsState()
// подтянуть список, когда появился пользователь Jellyfin // подтянуть список, когда появился пользователь Jellyfin
LaunchedEffect(userId) { LaunchedEffect(userId) {
@@ -255,12 +310,40 @@ fun CatalogScreen(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
CatalogTopBar(state = state) CatalogTopBar(state = state)
if (bulkEpisodes.isNotEmpty()) {
BulkActionsRow(
episodes = bulkEpisodes,
mirrors = state.mirrors,
label = bulkLabel,
contextName = state.subtitle(),
onMessage = { message -> scope.launch { snackbar.showSnackbar(message) } },
onStateRefresh = state::refresh,
)
}
// фильтр «только скачанные (и частично)» — остаётся только то, что скачано на каком-то устройстве
val visibleItems: List<JellyfinItem> = if (state.onlyDownloaded) {
state.items.filter { item ->
val id = item.Id
downloaded.containsKey(id) ||
localDownloaded.contains(id) ||
glassesDownloadedIds.contains(id) ||
glassesDownloads.containsKey(id)
}
} else {
state.items
}
if (state.loading && state.items.isEmpty()) { if (state.loading && state.items.isEmpty()) {
ScreenPlaceholder("загружаю…") ScreenPlaceholder("загружаю…")
} else if (state.error != null && state.items.isEmpty()) { } else if (state.error != null && state.items.isEmpty()) {
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить") ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
} else if (state.items.isEmpty()) { } else if (visibleItems.isEmpty()) {
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто") ScreenPlaceholder(
when {
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
state.query.isNotBlank() -> "ничего не нашлось"
else -> "пусто"
},
)
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
state = gridState, state = gridState,
@@ -270,15 +353,20 @@ fun CatalogScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(state.items, key = { it.Id }) { item -> items(visibleItems, key = { it.Id }) { item ->
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
ItemCard( ItemCard(
item = item, item = item,
posterUrl = posterUrlOrNull(app, item), posterUrl = posterUrlOrNull(app, item),
progress = watched[item.Id],
badge = mirrorBadge( badge = mirrorBadge(
itemId = item.Id, itemId = item.Id,
job = state.mirrors[item.Id], job = state.mirrors[item.Id],
downloaded = downloaded, downloaded = downloaded,
localDownloaded = localDownloaded, localDownloaded = localDownloaded,
glassesDownloading = videoProgress?.phase == DownloadPhase.DOWNLOADING,
glassesDownloadingPercent = videoProgress?.percent,
glassesDownloaded = item.Id in glassesDownloadedIds,
), ),
onClick = { onClick = {
when { when {
@@ -303,6 +391,7 @@ fun CatalogScreen(
} }
} }
} }
SnackbarHost(hostState = snackbar)
} }
} }
@@ -359,6 +448,21 @@ private fun CatalogTopBar(state: CatalogState) {
} }
} }
} }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = state.onlyDownloaded,
onCheckedChange = { state.onlyDownloaded = it },
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = "только скачанные (и частично)",
color = PhoneMuted,
fontSize = 11.sp,
)
}
} }
} }
@@ -372,9 +476,14 @@ private fun tabLabel(tab: CatalogTab): String = when (tab) {
private fun ItemCard( private fun ItemCard(
item: JellyfinItem, item: JellyfinItem,
posterUrl: String?, posterUrl: String?,
progress: WatchProgress?,
badge: MirrorBadge, badge: MirrorBadge,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
// «Просмотрено»: % — когда известна длительность, иначе время; бар — снизу постера
val percent = progress?.watchedPercent()
val watchedLabel = progress?.takeIf { it.positionMs > 0L }
?.let { p -> percent?.let { "$it%" } ?: msToClock(p.positionMs) }
Column( Column(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@@ -392,6 +501,24 @@ private fun ItemCard(
.padding(bottom = 6.dp) .padding(bottom = 6.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
) )
if (percent != null && percent > 0) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
.background(PhoneLine),
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(percent / 100f)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
.background(PhoneAccent),
)
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.padding(2.dp) .padding(2.dp)
@@ -408,13 +535,26 @@ private fun ItemCard(
) )
} }
} }
Text( Row(
text = item.Name ?: item.Id,
color = PhoneTextDim,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 2.dp), modifier = Modifier.padding(horizontal = 2.dp),
) verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = item.Name ?: item.Id,
color = PhoneTextDim,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (watchedLabel != null) {
Text(
text = watchedLabel,
color = PhoneMuted,
fontSize = 9.sp,
modifier = Modifier.padding(start = 4.dp, bottom = 1.dp),
)
}
}
} }
} }
@@ -1,35 +1,392 @@
package pw.binom.viewmate.phone.ui package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
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.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
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.ChatMessage
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.Session
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */ /**
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
* локального ассистента (та же очередь, что и голос с очков). Управление
* сессиями: новая, переключение (выпадающий список ИЛИ ступенчатый горизонтальный
* свайп ленты сообщений — на одну сессию за жест, без инерции; TZ-ui-four п.3),
* сброс текущей.
*/
@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<UiMsg>>(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(::uiMsgOf)
}
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(::uiMsgOf) }
if (fresh != messages) messages = fresh
}
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
}
}
// Ступенчатое переключение сессий свайпом (TZ-ui-four п.3): фиксированный
// порог смещения в px (плотность экрана).
val thresholdPx = with(LocalDensity.current) { CHAT_SESSION_SWIPE_THRESHOLD.toPx() }
/**
* Ступенчатое переключение: ровно ОДИН шаг (+1 — следующая, −1 — предыдущая),
* без перескока сразу на несколько сессий, без инерции/флинга;
* за границей списка — ничего.
*/
fun switchSessionBy(delta: Int) {
if (sessions.size < 2) return
val idx = sessions.indexOfFirst { it.id == activeId }.let { if (it >= 0) it else 0 }
val target = idx + delta
if (target < 0 || target >= sessions.size) return
val s = sessions[target]
activeId = s.id
app.selectChatSession(s.id)
}
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 = "Порфирий",
color = PhoneText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// Переключение сессий
Box {
TextButton(onClick = { sessionMenu = true }) {
Text(
text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет",
color = PhoneAccent,
fontSize = 12.sp,
)
}
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s ->
DropdownMenuItem(
text = { Text(s.title.ifBlank { "нет" }, 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(::uiMsgOf)
messages = history
}
}) {
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
}
}
Spacer(Modifier.height(2.dp))
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
val llmChoice by app.llmChoice.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B"
else "Серверный LLM · llm.binom.pw",
color = PhoneMuted,
fontSize = 10.sp,
)
Spacer(Modifier.width(6.dp))
// Переключить бэкенд прямо в чате (без захода в настройки).
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "→ серверный" else "→ локальный",
color = PhoneAccent,
fontSize = 10.sp,
modifier = Modifier
.clickable {
app.setLlmModelChoice(
if (llmChoice == LlmModelChoice.LOCAL) LlmModelChoice.REMOTE
else LlmModelChoice.LOCAL,
)
}
.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
Spacer(Modifier.height(4.dp))
// Лента сообщений
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
// Ступенчатая смена сессии (TZ-ui-four п.3): горизонтальный свайп
// ленты за фиксированный порог = ±одна сессия (свайп влево —
// следующая, вправо — предыдущая), мгновенно, без физики/инерции.
// foundation 1.8: onHorizontalDrag даёт (change, суммарный сдвиг x с
// начала жеста) — берём суммарный сдвиг без накопления по кадрам.
.pointerInput(sessions.size) {
var draggedPx = 0f
detectHorizontalDragGestures(
onHorizontalDrag = { _, amount -> draggedPx = amount },
onDragCancel = { draggedPx = 0f },
onDragEnd = {
when {
draggedPx <= -thresholdPx -> switchSessionBy(1)
draggedPx >= thresholdPx -> switchSessionBy(-1)
else -> Unit
}
draggedPx = 0f
},
)
},
) {
if (messages.isEmpty()) {
Text(
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
color = PhoneMuted,
fontSize = 13.sp,
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(messages) { msg ->
when (msg.role) {
ChatRole.TOOL -> ToolBubble(msg)
else -> MessageBubble(role = msg.role, text = msg.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 + UiMsg(ChatRole.USER, 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 = "Чат с Порфирием", text = text,
color = PhoneText, color = PhoneText,
fontSize = 18.sp, // Базовый шрифт сообщений (TZ-ui-four п.2): крупнее, чтобы читалось
fontWeight = FontWeight.Bold, // без напряжения (14.sp → 20.sp, ~+43%); вёртка баллонов не менялась.
) fontSize = 20.sp,
Text( modifier = Modifier
text = "в разработке (следующий этап)", .padding(horizontal = 10.dp, vertical = 6.dp)
color = PhoneMuted, .background(
fontSize = 13.sp, color = if (isUser) PhoneSurface else PhoneCard,
modifier = Modifier.padding(top = 8.dp), shape = RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 6.dp),
) )
} }
} }
/** Элемент ленты: tool-сообщения дополнительно несут имя тула (в БД toolCallId = имя тула). */
private data class UiMsg(
val role: ChatRole,
val content: String,
val tool: String? = null,
)
private fun uiMsgOf(m: ChatMessage) = UiMsg(
role = m.role,
content = m.content,
tool = if (m.role == ChatRole.TOOL) m.toolCallId else null,
)
/**
* Системный блок результата тула (ChatRole.TOOL): свёрнут по умолчанию —
* иконка + «тул · имя», тап раскрывает текст результата. Система видна
* по требованию, но не шумит в основной переписке.
*/
@Composable
private fun ToolBubble(msg: UiMsg) {
var expanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = Arrangement.Start,
) {
Column(
modifier = Modifier
.widthIn(max = 340.dp)
.clip(RoundedCornerShape(10.dp))
.background(PhoneCard)
.clickable { expanded = !expanded }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Build,
contentDescription = "тул",
tint = PhoneMuted,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = "тул · ${msg.tool ?: "результат"}",
color = PhoneMuted,
fontSize = 12.sp,
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = if (expanded) "свернуть" else "развернуть",
tint = PhoneMuted,
modifier = Modifier.size(14.dp),
)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Text(
text = msg.content,
color = PhoneTextDim,
fontSize = 12.sp,
)
}
}
}
}
/**
* Фиксированный порог ступенчатого свайпа переключения сессий (TZ-ui-four п.3):
* жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад.
*/
private val CHAT_SESSION_SWIPE_THRESHOLD = 120.dp
@@ -31,11 +31,13 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorJob
import java.util.Locale
/** Экраны приложения (простая навигация без nav-библиотеки). */ /** Экраны приложения (простая навигация без nav-библиотеки). */
enum class Screen { enum class Screen {
CATALOG, CATALOG,
DETAILS, DETAILS,
ACTIVITY,
GLASSES, GLASSES,
GLASSES_INFO, GLASSES_INFO,
CHAT, CHAT,
@@ -45,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
private val bottomTabs = listOf( private val bottomTabs = listOf(
BottomTab(Screen.CATALOG, "🎬", "Каталог"), BottomTab(Screen.CATALOG, "🎬", "Каталог"),
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
BottomTab(Screen.GLASSES, "🕶️", "Очки"), BottomTab(Screen.GLASSES, "🕶️", "Очки"),
BottomTab(Screen.CHAT, "💬", "Чат"), BottomTab(Screen.CHAT, "💬", "Чат"),
) )
@@ -76,11 +79,13 @@ fun BottomNavBar(current: Screen, onSelect: (Screen) -> Unit) {
data class MirrorBadge(val text: String, val bg: Color, val fg: Color) data class MirrorBadge(val text: String, val bg: Color, val fg: Color)
/** /**
* Статус зеркала элемента: * Статус зеркала элемента (коротко для бейджа каталога):
* - ⟳ N% — конвертируется; * - ⟳ N% — конвертируется;
* - ↓ часть — что-то скачано локально (телефон) или на очки; * - ✗ — ошибка конвертации;
* - ✓ — зеркало готово; * - ↓ N% — видео скачивается на очки;
* - ✗ — ошибка; * - ▶ — можно смотреть (видео уже на очках);
* - ✓ — зеркало готово (ещё не на очках);
* - ↓ часть — скачано что-то локально (телефон) или на очки;
* - нет — ничего нет. * - нет — ничего нет.
*/ */
fun mirrorBadge( fun mirrorBadge(
@@ -88,6 +93,9 @@ fun mirrorBadge(
job: MirrorJob?, job: MirrorJob?,
downloaded: Map<String, String>, downloaded: Map<String, String>,
localDownloaded: Set<String>, localDownloaded: Set<String>,
glassesDownloading: Boolean = false,
glassesDownloadingPercent: Int? = null,
glassesDownloaded: Boolean = false,
): MirrorBadge { ): MirrorBadge {
val status = job?.status val status = job?.status
val hasLocal = localDownloaded.contains(itemId) val hasLocal = localDownloaded.contains(itemId)
@@ -101,6 +109,16 @@ fun mirrorBadge(
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White) status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
// Видео уже летит на очки — конкретнее «✓ готово»
glassesDownloading -> MirrorBadge(
"↓ ${glassesDownloadingPercent ?: 0}%",
PhoneBlue,
PhoneBlueText,
)
// Видео на очках — можно смотреть
glassesDownloaded -> MirrorBadge("▶", PhoneGreen, PhoneGreenText)
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText) status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText) hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
@@ -168,7 +186,7 @@ fun bytesToSize(bytes: Long?): String {
val gb = 1024.0 * 1024 * 1024 val gb = 1024.0 * 1024 * 1024
val mb = 1024.0 * 1024 val mb = 1024.0 * 1024
return when { return when {
bytes >= gb -> "%.2f ГБ".format(bytes / gb) bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
bytes >= mb -> "%d МБ".format((bytes / mb).toLong()) bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
else -> "%d КБ".format((bytes / 1024).toLong()) else -> "%d КБ".format((bytes / 1024).toLong())
} }
@@ -291,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
} }
@Composable @Composable
fun SectionLabel(text: String) { fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text( Text(
text = text, text = text,
color = PhoneMuted, color = PhoneMuted,
@@ -299,6 +317,6 @@ fun SectionLabel(text: String) {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 6.dp), modifier = modifier.padding(bottom = 6.dp),
) )
} }
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.media.MirrorStatus import pw.binom.viewmate.core.media.MirrorStatus
import pw.binom.viewmate.core.phone.trackFileName
import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.FileDownloadState import pw.binom.viewmate.phone.FileDownloadState
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
@@ -79,16 +78,16 @@ fun DetailsScreen(
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState() val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesMediaDir by app.server.hub.mediaDir.collectAsState() val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
// статус зеркала + дорожки; пока конвертируется — опрашиваем // статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
LaunchedEffect(item.Id, refreshKey) { LaunchedEffect(item.Id, refreshKey) {
loading = true loading = true
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull() mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
loading = false loading = false
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") { while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
delay(3_000) delay(3_000)
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull() mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
} }
} }
@@ -179,13 +178,24 @@ fun DetailsScreen(
} }
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам // PlayVideo — только локальный путь на очках, никаких S3-URL очкам
val localPath = "file://$mediaDir/${item.Id}/video.mkv" val localPath = "file://$mediaDir/${item.Id}/video.mkv"
// «Продолжить с того места»: сохранённая позиция (null — не смотрели / досмотрели)
val resumeMs = if (alreadyPlaying) null else app.watchProgress.resumePosition(item.Id)
if (resumeMs != null) {
log("watch", "возобновление с сохранённой позиции: ${resumeMs} мс (${item.Name})")
}
val result: String val result: String
if (alreadyPlaying) { if (alreadyPlaying) {
result = "включил (видео уже играет на очках)" result = "включил (видео уже играет на очках)"
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук") log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
} else { } else {
result = try { result = try {
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk) actions!!.watch(
item.Id,
effectiveAudioIndex,
localVideoPath = localPath,
phoneAudioReady = audioOk,
startPositionMs = resumeMs ?: 0L,
)
} catch (e: Exception) { } catch (e: Exception) {
log("watch", "watch упал: ${e.message}") log("watch", "watch упал: ${e.message}")
snackbar.showSnackbar("watch упал: ${e.message}") snackbar.showSnackbar("watch упал: ${e.message}")
@@ -211,7 +221,7 @@ fun DetailsScreen(
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}") snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
} else { } else {
log("watch", "audio source: ${audio.source} (${audio.reason})") log("watch", "audio source: ${audio.source} (${audio.reason})")
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else (resumeMs ?: 0L)
app.audioSync.play(audio.source, startPositionMs) app.audioSync.play(audio.source, startPositionMs)
} }
} else { } else {
@@ -392,26 +402,28 @@ fun DetailsScreen(
keys.forEach { key -> keys.forEach { key ->
val name = finalFileName(key) val name = finalFileName(key)
val (finalFile, partFile) = trackFiles[key] ?: return@forEach val (finalFile, partFile) = trackFiles[key] ?: return@forEach
val isVideo = key == "video"
val phoneRaw = phoneFileState(finalFile, partFile) val phoneRaw = phoneFileState(finalFile, partFile)
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) { val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id) dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
} else { } else {
phoneRaw.second phoneRaw.second
} }
val glassesState = if (glassesFully) { // Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
FileDownloadState.DONE to 100 val state = when {
} else { isVideo && glassesFully -> FileDownloadState.DONE to 100
glassesFileState(perFileProgress[name]) isVideo -> glassesFileState(perFileProgress[name])
else -> phoneRaw.first to phonePercent
} }
DownloadFileRow( DownloadFileRow(
name = name, name = name,
sizeLabel = fileSizeOrProgress( device = if (isVideo) "очки" else "телефон",
sizeBytes = knownSizeByName[name], sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
phone = phoneRaw.first to phonePercent, "⏳ ${state.second}%"
glasses = glassesState, } else {
), bytesToSize(knownSizeByName[name])
phone = phoneRaw.first to phonePercent, },
glasses = glassesState, state = state,
) )
} }
} }
@@ -498,24 +510,13 @@ fun DetailsScreen(
} }
} }
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */ /** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */
private fun fileSizeOrProgress(
sizeBytes: Long?,
phone: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
): String = when {
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
else -> bytesToSize(sizeBytes)
}
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
@Composable @Composable
private fun DownloadFileRow( private fun DownloadFileRow(
name: String, name: String,
device: String,
sizeLabel: String, sizeLabel: String,
phone: Pair<FileDownloadState, Int>, state: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp) Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
@@ -523,13 +524,9 @@ private fun DownloadFileRow(
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("телефон", color = PhoneMuted, fontSize = 9.sp) Text(device, color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
FileChip(phone) FileChip(state)
Spacer(modifier = Modifier.width(12.dp))
Text("очки", color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp))
FileChip(glasses)
} }
} }
} }
@@ -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,125 @@ 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))
// Индикатор локальной модели — независимо от выбора бэкенда: пока модель
// скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке 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() }
}
}
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
if (
choice == LlmModelChoice.REMOTE &&
llmState !is LocalLlmModelState.Downloading &&
llmState !is LocalLlmModelState.Error
) {
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),
)
}
@@ -41,6 +41,7 @@ import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import pw.binom.viewmate.phone.glassesLivePercent import pw.binom.viewmate.phone.glassesLivePercent
/** Экран 3 — Статус очков + пульт. */ /** Экран 3 — Статус очков + пульт. */
@@ -61,6 +62,7 @@ fun GlassesScreen(
val playing by app.state.playingFlow.collectAsState() val playing by app.state.playingFlow.collectAsState()
val mode by app.state.modeFlow.collectAsState() val mode by app.state.modeFlow.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState() val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
val llmState by app.localLlmState.collectAsState()
val durationMs = currentItem?.RunTimeTicks?.div(10_000) val durationMs = currentItem?.RunTimeTicks?.div(10_000)
var showDownloads by remember { mutableStateOf(false) } var showDownloads by remember { mutableStateOf(false) }
@@ -71,6 +73,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 +188,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(
@@ -194,6 +224,18 @@ fun GlassesScreen(
) )
} }
// авто-скачивание локальной LLM-модели (Gemma 4 E2B)
val llmModelState = llmState
if (llmModelState is LocalLlmModelState.Downloading) {
Text(
text = "скачивание LLM-модели: ${llmModelState.percent}%",
color = PhoneYellow,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -360,6 +402,9 @@ private fun GlassesStatusCard(
fg = if (playing) PhoneBlueText else PhoneMuted, fg = if (playing) PhoneBlueText else PhoneMuted,
) )
} }
// Полевое испытание либы rayneo-mercury (BLE-протокол очков)
MercuryPanel()
} }
} }
@@ -0,0 +1,385 @@
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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.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 kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.viewmate.phone.MercuryBridge
/**
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
* Управление очками: подключение, экран, яркость и WiFi.
*/
@Composable
fun MercuryPanel() {
val scope = rememberCoroutineScope()
val state by MercuryBridge.state.collectAsState()
var brightness by remember { mutableIntStateOf(state.brightness) }
var autoBright by remember { mutableStateOf(state.automaticBrightness) }
var wifiOn by remember { mutableStateOf(state.wifiEnabled) }
var ssid by remember { mutableStateOf("") }
var wifiPass by remember { mutableStateOf("") }
var scanning by remember { mutableStateOf(false) }
// Синхронизируем локальные поля со статусом очков (после pullGeneralStatus).
LaunchedEffect(state.brightness) { brightness = state.brightness }
LaunchedEffect(state.automaticBrightness) { autoBright = state.automaticBrightness }
LaunchedEffect(state.wifiEnabled) { wifiOn = state.wifiEnabled }
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() } },
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 = { MercuryBridge.startPairing() },
enabled = !state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneAccent,
contentColor = Color(0xFF10251A),
),
) {
Text("🔗 паринг (QR)", fontSize = 12.sp)
}
if (state.pairCode != null) {
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { MercuryBridge.cancelPairing() },
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text("отмена", fontSize = 12.sp)
}
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.pairClassicAuto() } },
enabled = !state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF3A3F47),
contentColor = Color(0xFFD5DBDF),
),
) {
Text("🔗 паринг (вручную)", fontSize = 12.sp)
}
}
val pairCode = state.pairCode
if (pairCode != null) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Паринг: отсканируйте QR картинку камерой очков (она должна смотреть на экран телефона)",
color = PhoneMuted,
fontSize = 11.sp,
)
QrCode(pairCode, side = 280, modifier = Modifier.padding(top = 8.dp))
Spacer(modifier = Modifier.height(6.dp))
Text(
"Код: $pairCode",
color = PhoneText,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
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)
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.launchApp(MercuryBridge.VIEWMATE_GLASSES_PKG) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Запустить приложение", fontSize = 12.sp)
}
}
// ---- Яркость ----
Text(
text = "Яркость: $brightness%",
color = PhoneText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 12.dp),
)
Slider(
value = brightness.toFloat(),
onValueChange = { brightness = it.roundToInt() },
valueRange = 0f..100f,
enabled = state.connected,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Применить яркость", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = autoBright,
onCheckedChange = { nv ->
autoBright = nv
scope.launch { MercuryBridge.setAutoBrightness(nv) }
},
enabled = state.connected,
)
Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp)
}
// ---- WiFi ----
Row(
modifier = Modifier.padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "WiFi",
color = PhoneText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = wifiOn,
onCheckedChange = { nv ->
wifiOn = nv
scope.launch { MercuryBridge.wifiToggle(nv) }
},
enabled = state.connected,
)
Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp)
Spacer(modifier = Modifier.width(10.dp))
Button(
onClick = { scope.launch {
if (!state.wifiEnabled) MercuryBridge.wifiToggle(true)
scanning = true
delay(1500) // дать радио подняться
MercuryBridge.wifiScan()
delay(5000) // окно ожидания ответа очков
scanning = false
} },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp)
}
if (state.wifiSsid != null) {
Text(
"· ${state.wifiSsid}",
color = PhoneBlueText,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
}
TextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text("SSID сети", fontSize = 11.sp) },
singleLine = true,
enabled = state.connected,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
TextField(
value = wifiPass,
onValueChange = { wifiPass = it },
label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) },
singleLine = true,
enabled = state.connected,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
Button(
onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } },
enabled = state.connected && ssid.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Подключить к сети", fontSize = 12.sp)
}
if (state.wifiNetworks.isEmpty() && !scanning) {
Text(
"Список пуст: включи WiFi и нажми «Сканировать».",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(top = 8.dp),
)
}
if (state.wifiNetworks.isNotEmpty()) {
Text(
"Найдено сетей: ${state.wifiNetworks.size}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(top = 8.dp),
)
LazyColumn(
modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp),
) {
items(state.wifiNetworks) { net ->
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Text(
"${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}",
color = PhoneText,
fontSize = 11.sp,
)
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Button(
onClick = { scope.launch {
val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null
MercuryBridge.wifiConnect(net.ssid, pwd)
} },
enabled = state.connected,
) {
Text("Подкл.", fontSize = 11.sp)
}
if (net.saved) {
Button(
onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text("Забыть", fontSize = 11.sp)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,58 @@
package pw.binom.viewmate.phone.ui
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
/**
* Рендер QR-кода (pairCode) для паринга очков: очки сканируют его своей камерой.
* В стоковом приложении RayNeo QR кодирует ровно pairCode (8 символов).
*/
@Composable
fun QrCode(
text: String,
modifier: Modifier = Modifier,
side: Int = 200,
foreground: Color = Color.Black,
background: Color = Color.White,
) {
val density = LocalDensity.current.density
val sizePx = (side * density).toInt().coerceAtLeast(256)
val bitmap = remember(text, sizePx, foreground, background) {
renderQr(
text = text,
sizePx = sizePx,
foreground = foreground.toArgb(),
background = background.toArgb(),
)
}
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-код паринга",
modifier = modifier.size(side.dp),
)
}
private fun renderQr(text: String, sizePx: Int, foreground: Int, background: Int): Bitmap {
val bits = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx)
val w = bits.width
val h = bits.height
val pixels = IntArray(w * h)
for (y in 0 until h) {
val rowOffset = y * w
for (x in 0 until w) {
pixels[rowOffset + x] = if (bits.get(x, y)) foreground else background
}
}
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
}

Some files were not shown because too many files have changed in this diff Show More