71 Commits
1 ... main

Author SHA1 Message Date
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
151 changed files with 14197 additions and 868 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
fun ConnectionScreen() {
val connection = GlassesApp.instance.hostConnection
val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
// масштаб через layout — как в GlassesMovieScreen
val containerW = (screenWidthPx * scale).toInt()
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),
) {
... существующий контент ...
class PlaybackService : Service() {
companion object {
const val CHANNEL_ID = "viewmate_playback"
const val NOTIFICATION_ID = 1
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
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()
// канал уведомлений (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,
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
там есть `Side.LEFT/RIGHT` через CompositionLocal).
- **Старт — при каждом старте аудио** (3 места):
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
(строка ~127) и второй play (строка ~215)
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
приложение живо) — НО только если пользователь не против постоянного
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
уведомление — постоянный индикатор «ViewMate работает». Это проще и
покрывает и watch, и autostartAudio, и STT-сессии.
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
## Баг 2: скролл каталога теряется при навигации
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
приложения-компаньона — так работает, например, в паре с очками).
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
папку, потом ещё — и т.д.»
### 4. Проверка
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
**Фикс:** добавить в `CatalogState`:
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
В `CatalogScreen` composable:
```kotlin
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. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
3. На железе (через 142): установить APK, запустить, начать просмотр,
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
проверить: звук играет, видео на очках играет (позиция растёт в логах),
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
+47
View File
@@ -1,3 +1,5 @@
import java.io.File
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,6 +7,20 @@ plugins {
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 {
namespace = "pw.binom.viewmate.glasses"
compileSdk = 35
@@ -17,9 +33,30 @@ android {
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 {
release {
isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
}
}
@@ -61,6 +98,16 @@ dependencies {
implementation(project(":lib-core"))
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-движок)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets)
+7
View File
@@ -9,6 +9,13 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
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
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
}
}
@@ -4,13 +4,38 @@ import android.app.Application
import android.content.Context
import com.rayneo.arsdk.android.MercurySDK
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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.stt.GlassesAsr
import pw.binom.viewmate.glasses.stt.GlassesSttSession
import pw.binom.viewmate.glasses.stt.SttModelDownloader
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
data class ChatEntry(val role: String, val text: String)
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
object GlassesConfig {
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
// находится авто-обнаружением по mDNS (_viewmate._tcp).
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
}
/**
@@ -29,6 +54,26 @@ class GlassesApp : Application() {
lateinit var downloadManager: GlassesDownloadManager
private set
/** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */
lateinit var sttModel: SttModelDownloader
private set
/** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */
lateinit var asr: GlassesAsr
private set
/** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */
lateinit var sttSession: GlassesSttSession
private set
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
lateinit var logCollector: BatchingLogCollector
private set
/** Скоуп фоновых корутин (таймер лог-коллектора). */
private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val prefs by lazy {
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
}
@@ -36,7 +81,14 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
val scale = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f)
/**
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click,
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
*/
val listening = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
@@ -45,6 +97,34 @@ class GlassesApp : Application() {
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/**
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
*/
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/**
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
*/
val thinking = MutableStateFlow(false)
/**
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
*/
val answerScrollDelta = MutableStateFlow(0f)
/**
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
* (видео на очках не играет / данных ещё нет).
*/
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
/** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null
@@ -55,10 +135,18 @@ class GlassesApp : Application() {
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() {
super.onCreate()
instance = this
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
MercurySDK.init(this)
movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
@@ -71,6 +159,35 @@ class GlassesApp : Application() {
)
hostConnection.start()
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)")
// Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range).
sttModel = SttModelDownloader(File(filesDir, "models"), bgScope)
sttModel.ensure()
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity.
asr = GlassesAsr(File(filesDir, "models"))
sttSession = GlassesSttSession(
filesDir = filesDir,
scope = bgScope,
recognizer = { asr.recognize(it) },
modelReady = { sttModel.status.value.ready },
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
)
}
companion object {
@@ -99,7 +99,8 @@ internal class GlassesStateStore(private val file: File) {
/**
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part
* Каталог: filesDir/viewmate/{itemId}/video.mkv — на очки уходит только видео,
* звук (audio-N.ogg) не скачивается, он лежит на телефоне. Скачивание пишется в .part
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
*/
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.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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.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.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
@@ -31,19 +37,22 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode
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.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.protocolJson
/**
* Обвязка над [GlassesWsClient] для UI очков:
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
* Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
* DownloadFiles/DeleteFiles — менеджером скачивания).
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
*/
class HostConnection(
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
@@ -56,11 +65,14 @@ class HostConnection(
private val discovery = MdnsClient(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
@Volatile
private var client: GlassesWsClient? = null
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
@Volatile
private var transport: GlassesTransport? = null
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
@Volatile
private var lastUrl: String? = null
@@ -81,51 +93,97 @@ class HostConnection(
scope.launch {
while (isActive) {
delay(5_000)
client?.send(buildStatus())
sendToHost(buildStatus())
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
client?.send(
sendToHost(
PlaybackPosition(
positionMs = snapshot.positionMs,
playing = snapshot.playing,
itemId = snapshot.itemId,
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с на
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
* адресом (переиспользование последнего найденного); при его отсутствии —
* повторный mDNS-цикл.
*/
private suspend fun connectLoop() {
private suspend fun connectLoopWifi() {
val url = lastUrl ?: discoverPhoneUrl()
if (url.isEmpty()) {
log("app", "телефон не найден — повтор поиска через 3с")
delay(3_000)
connectLoop()
connectLoopWifi()
return
}
lastUrl = url
log("app", "подключаюсь к $url")
log("app", "подключаюсь к $url (WiFi)")
val c = GlassesWsClient(url)
client = c
c.connect(
onHostMessage = { msg -> handleHostMessage(msg) },
val t = WifiGlassesTransport(url)
transport = t
t.connect(
onMessage = { text -> handleHostMessageText(text) },
onConnected = {
_connected.value = true
_status.value = "подключено: $url"
_status.value = "подключено: ${t.name} $url"
addMessage("соединение установлено")
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()) {
withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю")
@@ -145,7 +203,7 @@ class HostConnection(
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
*/
private suspend fun discoverPhoneUrl(): String {
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
@@ -171,18 +229,29 @@ class HostConnection(
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) {
client?.send(msg)
val t = transport ?: return
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
}
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
fun sendPlaybackPositionNow() {
val c = client ?: return
if (transport == null) return
scope.launch {
val (positionMs, playing, itemId, audioIndex) =
val (positionMs, playing, itemId, audioIndex, durationMs) =
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 +313,11 @@ class HostConnection(
log("app", "scale → ${msg.scale}")
}
is SetVertical -> {
GlassesApp.instance.applyVertical(msg.vertical)
log("app", "vertical → ${msg.vertical}")
}
is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset()
@@ -266,15 +340,34 @@ class HostConnection(
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 ShowText -> {
addMessage("ShowText: ${msg.text}")
GlassesApp.instance.thinking.value = false
GlassesApp.instance.answerScrollDelta.value = 0f
GlassesApp.instance.assistantText.value = msg.text
}
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is ChatHistoryMsg -> {
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
GlassesApp.instance.chatHistory.value = msg.messages
.filter { it.role == "user" || it.role == "assistant" }
.map { ChatEntry(it.role, it.content) }
}
is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full
log("stt", "фраза: ${msg.phrase}")
@@ -286,8 +379,31 @@ class HostConnection(
}
is SttCancel -> {
log("stt", "отменено")
GlassesApp.instance.listening.value = false
when (msg.reason) {
SttCancel.REASON_CANCEL -> {
// Явная отмена диктовки двойным тапом: очки САМИ уже вернулись
// в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат,
// оверлей остаётся, микрофон готов к следующей записи). Это всего
// подтверждение, что телефон отменил STT-сессию (stt.reset()) —
// состояния (listening/thinking/оверлей) НЕ трогаем.
log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте")
}
SttCancel.REASON_TIMEOUT -> {
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
log("stt", "тишина 30с — выход из режима общения")
GlassesApp.instance.mainActivity?.onSttTimeout()
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
}
else -> {
// reason="user" — пустой клик без голоса: не выходим из режима,
// возвращаемся в «слушаю» (микрофон снова открыт).
log("stt", "отменено — возврат в «слушаю»")
GlassesApp.instance.thinking.value = false
GlassesApp.instance.mainActivity?.resumeSttListening()
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
}
}
}
is Shutdown -> {
@@ -306,7 +422,7 @@ class HostConnection(
}
fun stop() {
client?.close()
transport?.close()
discovery.stop()
scope.cancel()
}
@@ -1,6 +1,21 @@
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) {
println("[view-mate] $tag $message")
logCollector?.record(tag, message)
}
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Bundle
@@ -18,7 +19,6 @@ 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
@@ -36,7 +36,6 @@ 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 androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -49,12 +48,14 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.MediaCommand
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.core.protocol.SttText
import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.GestureDecision
import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.gestures.assistantMode
import pw.binom.viewmate.glasses.gestures.gestureDecision
import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
@@ -62,15 +63,11 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX
import pw.binom.viewmate.glasses.ui.panelOffsetX
class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
}
/**
* DEBUG-имитация жестов (эмулятор без трекпада):
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
@@ -79,9 +76,9 @@ class MainActivity : BaseEventActivity() {
private val debugReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.getStringExtra("type")) {
"triple_click" -> handleTripleClick()
"click" -> handleClick()
"double_click" -> handleDoubleClick()
"triple_click" -> onTempleAction(TempleAction.TripleClick())
"click" -> onTempleAction(TempleAction.Click())
"double_click" -> onTempleAction(TempleAction.DoubleClick())
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
}
}
@@ -116,6 +113,7 @@ class MainActivity : BaseEventActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlassesApp.instance.mainActivity = this
requestBtPermissions()
collectTempleActions()
disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -135,6 +133,9 @@ class MainActivity : BaseEventActivity() {
} else {
ConnectionScreen()
}
// Оверлей режима общения: поверх любого экрана (затемняет кино),
// виден в «слушаю»/«думаю»/«ответ виден».
AssistantOverlay()
}
}
}
@@ -188,78 +189,193 @@ class MainActivity : BaseEventActivity() {
}, null)
}
/**
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
* голосовой ввод (безусловный вызов), Click во время прослушивания —
* завершить распознавание, DoubleClick — отмена.
*/
private fun requestBtPermissions() {
if (!GlassesConfig.USE_BLUETOOTH) return
val granted = BtGlassesTransport.runtimePermissions().all {
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
if (granted) return
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
}
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
private fun collectTempleActions() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action ->
when (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)}")
}
onTempleAction(action)
}
}
}
}
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
private fun handleTripleClick() {
if (GlassesApp.instance.listening.value) {
/**
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
*/
private fun onTempleAction(action: TempleAction) {
val app = GlassesApp.instance
val state = AssistantState(
listening = app.listening.value,
thinking = app.thinking.value,
sttText = app.sttText.value,
assistantText = app.assistantText.value,
)
when (val decision = gestureDecision(action, state)) {
GestureDecision.Ignore ->
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
GestureDecision.CallAssistant -> startAssistantMode()
GestureDecision.TogglePlayback -> togglePlayback()
GestureDecision.SendPhrase -> sendPhrase()
GestureDecision.NewPhrase -> startNewPhrase()
GestureDecision.CancelDictation -> cancelDictation()
GestureDecision.ExitMode -> exitAssistantMode()
is GestureDecision.Seek -> seekBy(decision)
is GestureDecision.ScrollAnswer -> {
val acc = app.answerScrollDelta.value + decision.delta
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
app.answerScrollDelta.value = acc
}
}
}
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
private fun startAssistantMode() {
val app = GlassesApp.instance
if (app.listening.value) {
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
return
}
log("stt", "ассистент: слушаю")
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick)")
if (app.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
GlassesApp.instance.listening.value = true
GlassesApp.instance.sttText.value = ""
GlassesApp.instance.assistantText.value = ""
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
app.listening.value = true
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
app.sttSession.start()
}
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
private fun handleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "Click → завершить распознавание")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
return
}
/** Click (кино): плей/пауза — как сегодня. */
private fun togglePlayback() {
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))
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
private fun sendPhrase() {
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
val app = GlassesApp.instance
app.listening.value = false
app.thinking.value = true
app.sttSession.send()
}
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
private fun startNewPhrase() {
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
val app = GlassesApp.instance
app.assistantText.value = ""
app.sttText.value = ""
app.thinking.value = false
app.answerScrollDelta.value = 0f
app.listening.value = true
app.sttSession.start()
}
/**
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
*/
fun resumeSttListening() {
val app = GlassesApp.instance
app.thinking.value = false
app.listening.value = true
app.sttSession.start()
log("stt", "возврат в «слушаю» (SttCancel)")
}
/**
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на
* экране (даже если истории диалога ещё нет — отмена не закрывает чат).
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]).
*/
private fun cancelDictation() {
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
val app = GlassesApp.instance
app.sttSession.cancel()
app.sttText.value = ""
app.thinking.value = false
app.listening.value = true
app.sttSession.start()
}
/**
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
* слушать, пустой после лимита — выход из режима.
*/
fun onSttResult(text: String, auto: Boolean) {
val app = GlassesApp.instance
app.sttText.value = text
when {
text.isNotBlank() -> {
app.listening.value = false
app.thinking.value = true
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон")
lifecycleScope.launch {
app.hostConnection.sendToHost(SttText(text))
}
}
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
else -> {
log("stt", "клик без голоса — остаюсь в «слушаю»")
app.thinking.value = false
app.listening.value = true
app.sttSession.start()
}
return
}
log("gesture", "жест (резерв): DoubleClick")
}
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
/**
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
*/
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
private fun finishAssistantMode(reason: String) {
log("gesture", reason)
GlassesApp.instance.sttSession.stop()
val app = GlassesApp.instance
app.listening.value = false
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
}
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
private fun seekBy(seek: GestureDecision.Seek) {
log("gesture", "жест: свайп → ${seek.command}")
GlassesApp.instance.movieController.applyMediaCommand(
MediaCommandMsg(seek.command, seek.seekMs)
) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/**
@@ -287,11 +403,18 @@ class MainActivity : BaseEventActivity() {
override fun onPause() {
super.onPause()
log("ui", "onPause")
val was = GlassesApp.instance.movieController.pauseIfPlaying()
val app = GlassesApp.instance
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
}
val was = app.movieController.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("onPause: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
app.movieController.ensureWifiLock()
app.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас — пауза")
}
@@ -373,67 +496,43 @@ fun ConnectionScreen() {
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.background(Color.Black),
) {
Column(
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
// вертикаль (vertical) — только для видео; здесь контент всегда у верха
)
.background(Color.Black),
) {
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
Text(if (connected) "подключено к телефону" else "нет соединения")
Text(status, style = MaterialTheme.typography.bodyMedium)
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(
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (sttText.isBlank()) "слушаю..." else sttText,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
if (assistantText.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Text(
assistantText,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
Text(if (connected) "подключено к телефону" else "нет соединения")
Text(status, style = MaterialTheme.typography.bodyMedium)
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(messages) { msg ->
Text(msg, style = MaterialTheme.typography.bodySmall)
}
}
}
// Оверлей распознавания/ответа — в 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 itemId: String?,
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 {
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,182 @@
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):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
*
* @NONE — кино: ассистент не вызван.
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
* @ANSWER — ответ виден: listening=false и assistantText непуст.
*/
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState(
/** Микрофон открыт (TripleClick → слушаю). */
val listening: Boolean = false,
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
val thinking: Boolean = false,
/** Накопленные фразы (SttPhrase, full). */
val sttText: String = "",
/** Ответ ассистента (ShowText). */
val assistantText: String = "",
)
/**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*/
fun assistantMode(state: AssistantState): AssistantMode = when {
state.listening -> AssistantMode.LISTENING
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING
else -> AssistantMode.NONE
}
/**
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
* Матрица (docs/gestures-and-modes.md):
*
* | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------|
* | Кино | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | отмена | игнор | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/
sealed interface GestureDecision {
/** Жест в данном режиме не назначен (напр. DoubleClick в кино, свайпы в «слушаю»). */
data object Ignore : GestureDecision
/** TripleClick (кино): пауза фильма + открыть микрофон. */
data object CallAssistant : GestureDecision
/** Клик в кино: плей/пауза (как сегодня, MediaCommand.TOGGLE). */
data object TogglePlayback : GestureDecision
/** Клик в «слушаю»: закрыть микрофон, отправить стоп-STT (фраза на телефон). */
data object SendPhrase : GestureDecision
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
data object NewPhrase : GestureDecision
/**
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
* оверлей остаётся на экране даже при пустой истории диалога.
* Сторона очков: StopStt(cancel=true) на телефон.
*/
data object CancelDictation : GestureDecision
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
data object ExitMode : GestureDecision
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
data class Seek(val command: MediaCommand, val seekMs: Long) : GestureDecision
/** Свайпы при «ответе виден»: дельта скролла ответа, px. Положительная — к концу текста. */
data class ScrollAnswer(val delta: Float) : GestureDecision
}
/**
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
*
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
* меняем знак здесь, в одной точке).
*/
fun gestureDecision(
action: TempleAction,
state: AssistantState,
seekStepMs: Long = 30_000,
pageDeltaPx: Float = 200f,
): GestureDecision = when (val mode = assistantMode(state)) {
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
AssistantMode.NONE -> when (action) {
is TempleAction.Click -> GestureDecision.TogglePlayback
is TempleAction.TripleClick -> GestureDecision.CallAssistant
else ->
templeActionToCommand(action, seekStepMs)?.let { GestureDecision.Seek(it.command, it.seekMs) }
?: GestureDecision.Ignore
}
AssistantMode.LISTENING -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
else -> GestureDecision.Ignore
}
AssistantMode.THINKING -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
AssistantMode.ANSWER -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
is TempleAction.SlideContinuous ->
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
// два строка выше.
GestureDecision.ScrollAnswer(action.delta)
else -> GestureDecision.Ignore
}
}
/**
* Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»):
* возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый
* текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся
* на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true,
* т.к. listening=true). Закрытие ассистента — отдельное явное действие:
* двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]).
* Чистая функция — unit-тесты без Android.
*/
fun afterCancelDictation(state: AssistantState): AssistantState =
state.copy(listening = true, thinking = false, sttText = "")
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank()
/**
* Заголовок оверлея (над ответом). null — только ответ.
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт;
* - при «ответе виден» — последняя фраза как контекст (или null).
*/
fun overlayHeader(state: AssistantState): String? = when {
state.thinking && state.assistantText.isBlank() -> "думаю..."
state.listening -> state.sttText.ifBlank { "слушаю..." }
else -> state.sttText.takeIf { it.isNotBlank() }
}
/**
* Строка статуса модели 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.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -18,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import pw.binom.viewmate.glasses.GlassesApp
/**
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
@@ -36,12 +38,20 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
val d = data ?: return
val baseStyle = MaterialTheme.typography.bodyMedium
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
// + свежее значение от телефона); без видео — ничего не выводить.
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState()
Row(
modifier = modifier
.background(Color(0x99000000)) // полупрозрачный чёрный
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
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)
Spacer(Modifier.width(6.dp * scale))
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
@@ -0,0 +1,41 @@
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 =
recognizer.recognize(samples, sampleRate)
}
@@ -0,0 +1,118 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
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 на очках, по клику распознаём
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
* не участвует в распознавании. UX остаётся «говори → клик → текст».
*
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
* пусто после 30s-лимита (timeout) — автозавершение.
*
* @param onResult результат распознавания: text — фактический текст (может быть
* пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout).
*/
class GlassesSttSession(
private val filesDir: File,
private val scope: CoroutineScope,
private val recognizer: (FloatArray) -> String,
private val modelReady: () -> Boolean,
private val onResult: (text: String, auto: Boolean) -> Unit,
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
) {
/** Лимит сессии: как на телефоне (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
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
fun start() {
if (running) 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() && modelReady()) {
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
phrases.joinToString(" ") { recognizer(it) }
} else {
""
}
withContext(Dispatchers.Main) { onResult(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.AudioRecord
import android.media.MediaRecorder
import java.io.File
import java.io.FileInputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,65 +10,112 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
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) → колбэк.
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
* filesDir/samples (отладка, см. TASK.md этап 2).
* Поток микрофона очков (TASK-mic): 16 кГц s16 mono, полный поток БЕЗ VAD-нарезки.
*
* Список кусков/стоп — по [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(
private val scope: CoroutineScope,
private val onChunk: suspend (ByteArray) -> Unit,
) {
) : GlassesMic {
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()
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 {
AudioRecord(
MediaRecorder.AudioSource.MIC,
16_000,
MIC_SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
6_400,
bufferBytes,
)
}.getOrNull()
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder?.release()
log("stt", "микрофон недоступен — отладка: PCM из файла")
dump = null
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
return
}
dump = openLiveMicDump(filesDir)
val dumpWriter = dump
job = scope.launch(Dispatchers.IO) {
try {
recorder.startRecording()
log("stt", "микрофон запущен (16 кГц, s16, mono)")
val buf = ByteArray(3_200)
log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)")
val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс
val chunk = ArrayList<ShortArray>(MIC_CHUNK_FRAMES)
while (isActive && shouldRun()) {
val n = recorder.read(buf, 0, buf.size)
if (n > 0) {
val chunk = if (n == buf.size) buf else buf.copyOf(n)
onChunk(chunk)
val n = recorder.read(frame, 0, FRAME_SIZE) // кадры
if (n <= 0) continue
val data = if (n == FRAME_SIZE) frame else frame.copyOf(n)
// СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки.
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) {
log("stt", "микрофон: ${t.message}")
} finally {
runCatching { recorder.stop() }
recorder.release()
runCatching { dumpWriter?.close() }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
log("stt", "микрофон остановлен")
}
}
}
fun stop() {
override fun stop() {
job?.cancel()
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). */
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
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,222 @@
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
/**
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
* каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
* «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
* в том же scale-контейнере, что у экранов. Режим общения явный
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
* Состояния: «слушаю»/«думаю...» — заголовок,
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
* [GlassesApp.answerScrollDelta]).
*/
@Composable
fun AssistantOverlay() {
val app = GlassesApp.instance
val listening by app.listening.collectAsState()
val thinking by app.thinking.collectAsState()
val sttText by app.sttText.collectAsState()
val assistantText by app.assistantText.collectAsState()
val chatHistory by app.chatHistory.collectAsState()
val state = AssistantState(listening, thinking, sttText, assistantText)
if (!overlayVisible(state)) return
val modelStatus by app.sttModel.status.collectAsState()
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
val modelLine = if (state.listening) 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) общий в GlassesApp. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) {
app.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
app.answerScrollDelta.value = 0f
if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150))
}
}
}
overlayBox(app, state, chatHistory, scrollState, 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()
.background(Color.Black),
) {
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) {
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) }
val scale by GlassesApp.instance.scale.collectAsState()
val vertical by GlassesApp.instance.vertical.collectAsState()
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView.
@@ -103,34 +104,47 @@ fun GlassesMovieScreen(
maxHeight = containerH - 25,
)
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
) {
Row(
modifier = Modifier.fillMaxWidth().background(Color.Black),
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
),
) {
if (SCALE_DEBUG_SQUARE) {
// Тестовый режим: большой красный квадрат вместо видео — быстрая
// проверка масштаба/позиций скрином без запуска видео.
Box(Modifier.fillMaxSize().background(Color.Red))
} else {
BinocularVideoPlayer(
player = player,
renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px),
)
Row(
modifier = Modifier.fillMaxWidth().background(Color.Black),
// Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
// Row-центрирование съедало вертикаль — картинка оставалась «по центру».
verticalAlignment = Alignment.Top,
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
) {
if (SCALE_DEBUG_SQUARE) {
// Тестовый режим: большой красный квадрат вместо видео — быстрая
// проверка масштаба/позиций скрином без запуска видео.
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(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale,
modifier = Modifier
.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 =
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. */
private const val SCALE_DEBUG_SQUARE = false
@@ -4,6 +4,9 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import pw.binom.viewmate.glasses.ui.Side
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 — смещение контейнера глаза к центру (к носу):
@@ -34,4 +37,81 @@ class ScaleOffsetTest {
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 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,316 @@
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): переходы состояний, свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
*/
class AssistantGesturesTest {
private val movie = AssistantState()
private val listening = AssistantState(listening = true)
private val thinking = AssistantState(thinking = true)
private val answer = AssistantState(assistantText = "ответ")
// ————— режим по состоянию —————
@Test
fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
}
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
@Test
fun listeningWinsOverEverything() {
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
@Test
fun answerWinsOverThinking() {
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
}
@Test
fun thinkingOnlyWhenNoAnswer() {
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
}
// ————— кино ( NONE) —————
@Test
fun movieMatrix() {
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), movie))
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 30_000),
gestureDecision(slideForward(), movie),
)
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_BACKWARD, 30_000),
gestureDecision(slideBackwardAction(), movie),
)
// Свайп «плавного» типа без ответа не управляет скроллом → игнор.
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), movie))
}
@Test
fun movieSeekStepIsParameter() {
val d = gestureDecision(slideForward(), movie, seekStepMs = 10_000)
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
}
// ————— слушаю —————
@Test
fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
}
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
@Test
fun thinkingMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), thinking))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
}
// ————— «ответ виден» —————
@Test
fun answerMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
}
@Test
fun answerPageDeltaIsParameter() {
assertEquals(
GestureDecision.ScrollAnswer(80f),
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
)
}
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@Test
fun sessionTransitions() {
var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.copy(listening = true)
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
assertEquals(AssistantMode.THINKING, assistantMode(s))
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
s = s.copy(thinking = false, assistantText = "ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
// Выход: оверлей невидим, кино снова управляется кликом.
val exited = s.copy(assistantText = "")
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
@Test
fun cancelStaysInConversation() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.CancelDictation, decision)
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
val after = afterCancelDictation(listening)
assertEquals(AssistantMode.LISTENING, assistantMode(after))
assertTrue(overlayVisible(after))
// Двойной тап в «слушаю» после отмены — снова просто отмена новой записи.
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), after))
// Отмена посреди диалога: пользователь говорит новую фразу → ответ виден
// (listening уже закрыт отправкой) — выход (как и раньше) DoubleClick.
val afterWithAnswer = after.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(afterWithAnswer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), afterWithAnswer))
}
/** Отмена первой записи (пустая история): оверлей НЕ закрывается (TZ-ui-four п.1). */
@Test
fun cancelFirstRecordingKeepsOverlay() {
// Чистый «кино»-снимок → «слушаю» (вызов ассистента) → отмена записи.
val listeningNow = movie.copy(listening = true)
val cancelled = afterCancelDictation(listeningNow)
assertTrue(cancelled.assistantText.isBlank())
assertTrue(cancelled.listening)
// Оверлей виден, хотя ни фразы, ни ответа нет — «слушай готов».
assertTrue(overlayVisible(cancelled))
// И только явный выход (после диалога) прячет оверлей.
val exited = cancelled.copy(listening = false, thinking = false, assistantText = "ответ")
val closed = exited.copy(listening = false, thinking = false, assistantText = "")
assertFalse(overlayVisible(closed))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), closed))
}
/** Отмена сбрасывает накопленный текст текущей (отменяемой) записи. */
@Test
fun cancelDictationClearsCancelledText() {
val after = afterCancelDictation(listening.copy(sttText = "слова до отмены"))
assertEquals("", after.sttText)
assertTrue(after.listening)
}
// ————— оверлей —————
@Test
fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
assertFalse(overlayVisible(movie))
}
@Test
fun overlayVisibleInListeningThinkingAndAnswer() {
assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer))
}
@Test
fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking))
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
assertEquals(
"думаю...",
overlayHeader(AssistantState(listening = true, thinking = true)),
)
}
@Test
fun listeningHeaderFallsBackToListening() {
assertEquals("слушаю...", overlayHeader(listening))
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
}
@Test
fun answerHeaderIsLastPhraseOrNull() {
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
assertNull(overlayHeader(answer))
assertNull(overlayHeader(movie))
}
// ————— строка статуса модели —————
@Test
fun 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.io.File
plugins {
alias(libs.plugins.android.application)
@@ -7,6 +8,20 @@ plugins {
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; пустой = ещё не задан.
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
val llmApiKey: String = run {
@@ -47,9 +62,30 @@ android {
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 {
release {
isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
}
}
@@ -94,13 +130,28 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
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-совместимый)
implementation(libs.koog.openai.client)
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")
// Офлайн-распознавание Qwen3-ASR (asr-kmp, caffeine nexus) — Qwen3AsrStt
implementation("pw.binom.asr:asr-qwen3-android:4")
// WS-сервер телефона (CIO — работает на Android)
implementation("io.ktor:ktor-server-core: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")
testImplementation(kotlin("test"))
// Тестовый стриминговый VAD (Silero на ONNX, JVM) — из mavenLocal
testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT")
testImplementation("io.ktor:ktor-server-test-host:3.3.0")
testImplementation("io.ktor:ktor-client-websockets:3.3.0")
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
+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
android:name="android.permission.NEARBY_WIFI_DEVICES"
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.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
android:name=".PhoneApp"
@@ -16,13 +28,28 @@
android:allowBackup="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
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -20,34 +22,66 @@ import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File
import kotlin.math.abs
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */
/** Решение одного sync-цикла: speed — желаемая скорость аудио; seekToMs не null → редкий жёсткий seek; playing не null → play/pause. */
internal data class SyncDecision(
val speed: Float = 1f,
val seekToMs: Long? = null,
val playing: Boolean? = null,
)
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */
internal const val SYNC_THRESHOLD_MS = 1000L
/** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
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):
* - |audio - video| > [thresholdMs] → перемотать на позицию очков;
* - isPlaying очков отличается от состояния аудио → play/pause.
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
* - иначе — пропорциональная коррекция: звук отстаёт (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(
audioPosition: Long,
videoPosition: Long,
audioPlaying: Boolean,
videoPlaying: Boolean,
thresholdMs: Long = SYNC_THRESHOLD_MS,
): 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 {
videoPlaying == audioPlaying -> null
videoPlaying -> true
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 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
/** IsPlaying очков из последнего sync. */
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)
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
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
get() = player != null
@@ -139,43 +209,62 @@ class AudioSyncPlayer(private val context: Context) {
} else {
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.prepare()
p.seekTo(startPositionMs.coerceAtLeast(0L))
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()
}
}
/**
* Коррекция по позиции очков: 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 {
val p = player ?: return@post
lastVideoPositionMs = positionMs
lastVideoPlaying = isPlaying
val audioPos = p.currentPosition
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying)
decision.seekToMs?.let {
p.seekTo(it)
lastVideoPositionMs = it
// Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с),
// второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с).
main.postDelayed({
val pp = player ?: return@postDelayed
val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду
if (abs(pp.currentPosition - target) > 500) {
pp.seekTo(target)
log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})")
// Латентность линка очки→телефон: сколько мс прошло от отправки позиции на очках до обработки здесь.
// Часы синхронизированы через mercury.syncTime(), так что это чистая транспортная задержка.
if (sentTs > 0L) {
val measured = (System.currentTimeMillis() - sentTs).toDouble()
if (measured in 0.0..LINK_LATENCY_MAX_MS) {
linkLatencyMs = linkLatencyMs * LINK_LATENCY_ALPHA + measured * (1 - LINK_LATENCY_ALPHA)
val now = System.currentTimeMillis()
if (now - lastLatencyLoggedAt > 5000) {
lastLatencyLoggedAt = now
log("audio sync:", "латентность линка: ${linkLatencyMs.toInt()} мс")
}
}, 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() }
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
}
}
@@ -198,7 +287,10 @@ class AudioSyncPlayer(private val context: Context) {
ticker = null
val p = player
player = null
lastVideoPlaying = false
sourceLabel = "нет"
_positionMs.value = 0L
_diffMs.value = 0L
if (p != null) {
p.playWhenReady = false
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
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 android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
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.PhoneState
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.Hello
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.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
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.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
@@ -41,14 +39,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
/**
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
* DownloadProgress → карта скачивания на очки).
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
*/
class GlassesHub(
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)
val connected: StateFlow<Int> = _connected.asStateFlow()
@@ -96,46 +98,58 @@ class GlassesHub(
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
* рестарта телефона (см. 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].
*/
@Volatile
var stt: SttStreamer? = null
/**
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
* на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
* отсутствуют — только полный текст (по клику или по тишине 30 с).
*/
@Volatile
var onStopFullText: ((String) -> Unit)? = null
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
@Volatile
var sttFactory: (() -> SttStreamer?)? = null
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
fun add(connId: String) {
sessions[connId] = Unit
_connected.value = sessions.size
log("hub", "очки подключились, всего: ${sessions.size}")
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
}
fun remove(session: DefaultWebSocketServerSession) {
sessions.remove(session)
fun remove(connId: String) {
sessions.remove(connId)
_connected.value = sessions.size
log("hub", "очки отключились, всего: ${sessions.size}")
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
}
/** Отправить сообщение всем подключённым очкам. */
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
for (session in sessions.keys) {
try {
session.send(text)
} catch (e: Exception) {
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
}
for (connId in sessions.keys) {
runCatching { transport.send(connId, text) }
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
}
}
/** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
suspend fun handle(connId: String, text: String) {
val msg = try {
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
} catch (e: Exception) {
@@ -144,8 +158,8 @@ class GlassesHub(
}
when (msg) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
}
is Gesture -> {
@@ -182,13 +196,18 @@ class GlassesHub(
this.state.playing = msg.playing
val state = if (msg.playing) "играет" else "пауза"
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 -> {
log("glasses", "очки выключились → пауза (${msg.reason})")
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 -> {
@@ -199,80 +218,89 @@ class GlassesHub(
is StopStt -> {
if (msg.cancel) {
log("stt", "распознавание отменено (очки)")
send(session, SttCancel())
send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
stt?.reset()
} else {
val full = stt?.finish() ?: ""
log("stt", "ВЕСЬ ТЕКСТ: $full")
send(session, SttDone(full))
stt?.reset()
}
}
}
}
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
session.send(text)
}
}
/** 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)
if (full.isNotBlank()) {
send(connId, SttDone(full))
val phrase = full.trim()
log("stt", "фраза в LLM (по клику): $phrase")
onStopFullText?.invoke(phrase)
} else {
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
log("stt", "фразы не было — очки в «слушаю»")
broadcast(SttCancel(reason = SttCancel.REASON_USER))
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
hub.remove(this)
}
// Новый путь: очки распознали речь локально и прислали готовый текст —
// в LLM тем же каналом, что и StopStt(cancel=false). Old SttAudio/StopStt
// остаются фолбэком для старых очей.
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).
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
* (SPP). Поведение по умолчанию (WiFi) не меняется.
*/
class GlassesServer(
private val port: Int = PhoneConfig.SERVER_PORT,
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). */
val sender: GlassesSender = object : GlassesSender {
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() {
if (server != null) return
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesServerModule(hub)
if (acceptJob != null) return
acceptJob = scope.launch {
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() {
server?.stop(1_000, 3_000)
server = null
transport.close()
scope.cancel()
}
}
@@ -1,6 +1,31 @@
package pw.binom.viewmate.phone
/** Единый лог телефона: простой println (работает и в unit-тестах). */
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
import android.util.Log
import pw.binom.viewmate.core.log.BatchingLogCollector
/**
* Единый лог телефона.
* На устройстве — 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
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.content.ContextCompat
import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.agent.LlmDebug
import pw.binom.viewmate.phone.ui.ViewMateApp
@@ -15,6 +19,8 @@ import java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestBtPermissions()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent {
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]. */
private fun runLlmDebug() {
showOverlay("[llm] вызываю llm.binom.pw...")
@@ -0,0 +1,410 @@
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"
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
}
/** Автоблокировка экрана через 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.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,21 +17,45 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
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.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.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.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.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.Gemma4E2B
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
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.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.WhisperStt
/**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
@@ -39,8 +65,13 @@ class PhoneApp : Application() {
val state: PhoneState = PhoneState()
/** WS-сервер для очков. */
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
val server: GlassesServer = GlassesServer(
port = PhoneConfig.SERVER_PORT,
state = state,
useBluetooth = PhoneConfig.USE_BLUETOOTH,
btContext = this,
)
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
@@ -59,6 +90,13 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
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"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
@@ -85,76 +123,313 @@ class PhoneApp : Application() {
private var assistantLock = Any()
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
private fun initialLocalLlmState(): LocalLlmModelState =
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
/** Файл локальной модели: getExternalFilesDir(null)/gemma-4-E2B-it.litertlm. */
fun localModelFile(): File = File(getExternalFilesDir(null) ?: filesDir, Gemma4E2B.FILE_NAME)
/** Сменить выбор модели: персист + пересоздание ассистента при следующей фразе. */
fun setLlmModelChoice(choice: LlmModelChoice) {
if (_llmChoice.value == choice) return
llmPrefs.set(choice)
_llmChoice.value = choice
synchronized(assistantLock) { assistant = null }
log("llm", "выбрана модель: ${choice.name}")
}
/**
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
* Повторный вызов во время скачивания — no-op.
*/
fun downloadLocalLlmModel() {
val current = localLlmDownloadJob
if (current != null && current.isActive) return
localLlmDownloadJob = scope.launch {
val target = localModelFile()
_localLlmState.value = LocalLlmModelState.Downloading(0)
try {
GemmaModelDownloader(target).download { percent ->
_localLlmState.value = LocalLlmModelState.Downloading(percent)
}
_localLlmState.value = LocalLlmModelState.Ready
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
log("llm", "не удалось скачать модель: ${e.message}")
}
}
}
/** Остановить скачивание локальной модели (частичный файл удаляется). */
fun cancelLocalLlmDownload() {
localLlmDownloadJob?.cancel()
}
/**
* Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу.
* LLM-клиент берётся по текущему выбору [llmChoice]: серверный или локальный
* LiteRT-LM; при смене выбора ассистент создаётся заново.
*/
private fun ensureAssistant(): Assistant? {
synchronized(assistantLock) {
assistant?.let { return it }
if (BuildConfig.LLM_API_KEY.isBlank()) return null
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) })
.also { assistant = it }
val choice = _llmChoice.value
if (assistant != null) {
if (assistantChoice == choice) return assistant
assistant = null
}
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 на очки.
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
*/
private suspend fun assistantLoop() {
for (phrase in assistantChannel) {
log("assistant", "получена фраза: $phrase")
val assistant = ensureAssistant()
if (assistant == null) {
log("assistant", "LLM-ключ не задан — фраза пропущена")
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
continue
}
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
val answer = runCatching { assistant.process(phrase) }
val answer = runCatching {
val active = _activeSessionId.value
if (active != null) {
assistant.processInSession(active, phrase)
} else {
assistant.process(phrase)
}
}
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
server.hub.broadcast(ShowText(answer))
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
server.hub.broadcast(chatHistoryMsg())
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
}
}
/**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* История активной (или последней) сессии для очков: только роли 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). Вызывается из хаба
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
* на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
*/
private fun ensureStt(): SttStreamer? {
synchronized(sttLock) {
sttStreamer?.let { return it }
val modelsDir = File(filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx")
val encoder = File(modelsDir, "small-encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt")
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
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 (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null
}
val whisper = WhisperStt(
encoderPath = encoder.absolutePath,
decoderPath = decoder.absolutePath,
tokensPath = tokens.absolutePath,
val stt: PhraseRecognizer = Qwen3AsrStt(
convFrontendPath = qwenConv.absolutePath,
encoderPath = qwenEnc.absolutePath,
decoderPath = qwenDec.absolutePath,
tokenizerDir = qwenTok.absolutePath,
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(
stt = whisper,
vadModelPath = vadModel.absolutePath,
onPhrase = { phrase, full ->
log("stt", "фраза: $phrase")
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
scope.launch { assistantChannel.send(phrase) }
},
stt = stt,
onSilence30s = {
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
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов (qwen3_asr)")
return s
}
}
@@ -189,10 +464,39 @@ class PhoneApp : Application() {
super.onCreate()
instance = this
PlaybackService.start(this)
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
MercuryBridge.init(this)
server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
DebugHttpServer.start(this)
OpenAiHttpServer.start(this)
nsd.publish()
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() }
val lastPositionAt = AtomicLong(0)
@@ -200,11 +504,25 @@ class PhoneApp : Application() {
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex ->
// + прогресс просмотра (resume + индикация в каталоге)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs, durationMs ->
lastPositionAt.set(System.currentTimeMillis())
lastWasPlaying.set(playing)
audioSync.sync(positionMs, playing)
audioSync.sync(positionMs, playing, sentTs)
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: очки молчат (экран погас/радио спит) — пауза звука по таймауту
@@ -264,6 +582,7 @@ class PhoneApp : Application() {
}
override fun onTerminate() {
PlaybackService.stop(this)
nsd.unpublish()
sttStreamer?.close()
server.stop()
@@ -273,5 +592,8 @@ class PhoneApp : Application() {
companion object {
lateinit var instance: PhoneApp
private set
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
}
}
@@ -12,8 +12,23 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
/** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */
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. */
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-вызовы не пересекаются).
*
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
* затем делает повторный вызов LLM. Невидимые системные тулы
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
* пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
* возвращается пользователю как финальный ответ.
*/
class Assistant(
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,
) {
/**
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
*/
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)
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 answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) }
?: return "Ассистент не ответил (таймаут)"
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
chatDao.touchSession(sessionId)
return answer
var result = callLlm(history, sessionId)
var lastToolResult: String? = null
var visibleCalls = 0
var llmCalls = 1
// Цикл тулов: до [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 {
// TODO(персона): промпт выберет пользователь
const val SYSTEM_PROMPT =
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
/** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */
const val MAX_TOOL_CALLS = 3
/** Общий лимит 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 }
}
/** Сессия по 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) {
db.writableDatabase.update(
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
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.llm.LLMCapability
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.llm.LLMProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
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-совместимый эндпоинт
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
* LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
* → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
*
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
*
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
* Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
* («Ассистент недоступен: …»), сам клиент не падает тихо.
*/
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 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(
apiKey = apiKey,
settings = OpenAIClientSettings(baseUrl = baseUrl),
httpClientFactory = OkHttpKoogHttpClient.Factory(),
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class ChatMessageDto(
val role: String,
val content: String,
)
private val llmModel: LLModel = LLModel(
provider = LLMProvider.OpenAI,
id = model,
capabilities = listOf(
LLMCapability.Completion,
LLMCapability.OpenAIEndpoint.Completions,
),
@Serializable
private data class ChatRequest(
val model: String,
val messages: List<ChatMessageDto>,
val max_tokens: Int = 1024,
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 {
val prompt = prompt("assistant-chat") {
if (system != null) {
this.system(system)
}
for (msg in messages) {
when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
ChatRole.TOOL -> Unit
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
val dto = ArrayList<ChatMessageDto>()
if (system != null) dto += ChatMessageDto("system", system)
for (msg in messages) {
when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
}
}
}
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 — пропускаю")
return
}
val client = LlmClient(apiKey = apiKey, model = model)
val client = RemoteLlmClient(apiKey = apiKey, model = model)
try {
val reply = runBlockingSafe {
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 attachments: List<Attachment>,
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 в строковые колонки БД. */
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
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileInputStream
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
object SttDebug {
private const val TAG = "stt"
/**
* Распознаёт [wavPath] по фразам (VAD + Whisper).
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
@@ -23,16 +20,24 @@ object SttDebug {
// через: adb push /data/local/tmp/... + run-as pkg cp
val modelsDir = File(context.filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "small-tokens.txt").absolutePath
val convFrontend = File(modelsDir, "conv_frontend.onnx").absolutePath
val encoder = File(modelsDir, "encoder.int8.onnx").absolutePath
val decoder = File(modelsDir, "decoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "tokenizer").absolutePath
log("[stt] создаём VAD...")
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
log("[stt] фраз (VAD): ${segments.size}")
log("[stt] создаём Whisper-small (int8, CPU)...")
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4)
log("[stt] создаём Qwen3-ASR-0.6B (int8, CPU)...")
val stt = Qwen3AsrStt(
convFrontendPath = convFrontend,
encoderPath = encoder,
decoderPath = decoder,
tokenizerDir = tokens,
numThreads = 4,
hotwords = "тулсет,тулы,умеешь",
)
val parts = ArrayList<String>()
for ((i, seg) in segments.withIndex()) {
val startMs = seg.first / 16
@@ -46,14 +51,14 @@ object SttDebug {
log("[stt] ВЕСЬ ТЕКСТ: $all")
all
} catch (t: Throwable) {
Log.e(TAG, "[stt] ошибка: ${t.message}", t)
log("[stt] ошибка: ${t.message}")
t.printStackTrace()
null
}
}
private fun log(msg: String) {
Log.i(TAG, msg)
pw.binom.viewmate.phone.log("stt", msg)
println(msg)
}
}
@@ -1,53 +1,47 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.concurrent.withLock
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
val n = pcm.size / 2
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
}
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.phone.log
/**
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
* Whisper (в отдельном потоке) → колбэк [onPhrase].
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
* [PhraseRecognizer] с предварительным обрезанием краевой тишины ([trimEdges]).
*
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
* идёт в однопоточном executor-е (не блокирует приём аудио).
*
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s]
* (автo-отмена, вызывается один раз за сессию).
* Тишина [SILENCE_TIMEOUT_MS] (с момента первого [accept]) → автo-финиш:
* накопленный буфер распознаётся (как [finish]) и результат отдаётся в
* [onAutoFinished], затем сессия сбрасывается и вызывается [onSilence30s]
* (автo-отмена, один раз за сессию).
*/
class SttStreamer(
private val stt: WhisperStt,
private val vadModelPath: String,
private val onPhrase: (phrase: String, full: String) -> Unit,
private val stt: PhraseRecognizer,
private val onSilence30s: () -> Unit,
/** Полный текст сессии, распознанный автo-финишем по тишине (см. [onSilence30s]). */
private val onAutoFinished: (full: String) -> Unit,
private val dumpPcm: File? = null,
) {
companion object {
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 vad: Vad = Vad(null, vadConfig())
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "stt-recognize").apply { isDaemon = true }
}
@@ -64,22 +58,40 @@ class SttStreamer(
@Volatile
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 ждёт её завершения. */
private var lastFuture: Future<*>? = null
private var lastFuture: java.util.concurrent.Future<*>? = null
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
while (true) {
val deadline = deadlineMs
if (deadline > 0 && System.currentTimeMillis() > deadline) {
lock.withLock {
if (deadlineMs == deadline) {
deadlineMs = 0
resetLocked()
onSilence30s()
}
// Снимаем срок и освобождаем lock до распознавания — watchdog не держит
// lock при вызове finish (иначе блокировка-в-блокировке c accept).
val expired = lock.withLock {
if (deadlineMs != deadline) return@withLock false
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 }
@@ -91,110 +103,92 @@ class SttStreamer(
lock.withLock {
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
val floats = s16leToFloat(pcm)
var i = 0
if (pending.isNotEmpty()) {
val need = WINDOW_SIZE - pending.size
val take = minOf(need, floats.size)
val combined = pending + floats.copyOfRange(0, take)
i += take
if (combined.size == WINDOW_SIZE) {
feedWindow(combined)
pending = FloatArray(0)
} else {
pending = combined
return
}
}
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)
sessionPcm = sessionPcm + floats
}
val out = dumpOut
if (out != null) {
runCatching { out.write(pcm) }
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
}
}
/**
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после
* вызова нужно [reset] (состояние сессии снимает вызывающий).
* Завершить сессию (клик): распознать весь буфер сессии одним вызовом
* (хвост [MAX_FULL_BUFFER_SAMPLES], если длиннее) с обрезанием краевой тишины
* — [full] перезаписывается результатом. Вернуть полный текст.
* Буферы НЕ сбрасывает — после вызова нужен [reset] (состояние сессии
* снимает вызывающий).
*/
fun finish(): String {
lock.withLock {
vad.flush()
while (!vad.empty()) {
val seg = vad.front()
recognizeSegment(seg.samples)
vad.pop()
}
val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
recognizeFullBufferLocked()
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() {
lock.withLock { resetLocked() }
}
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */
/** Освободить ресурсы и сторожевой поток (при выключении приложения). */
fun close() {
lock.withLock {
deadlineMs = 0
runCatching { vad.release() }
}
lock.withLock { deadlineMs = 0 }
runCatching { dumpOut?.close() }
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
executor.shutdownNow()
watchdog.interrupt()
}
private fun resetLocked() {
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
runCatching { vad.flush() }
while (!vad.empty()) vad.pop()
session++
full = ""
pending = FloatArray(0)
sessionPcm = FloatArray(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
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -26,6 +31,7 @@ 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.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -43,7 +49,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
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.WatchProgress
import pw.binom.viewmate.phone.log
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
@@ -83,6 +92,8 @@ class CatalogState(
private set
var scrollOffset by mutableStateOf(0)
private set
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
var onlyDownloaded by mutableStateOf(false)
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
@@ -195,7 +206,6 @@ class CatalogState(
}
}
items = loaded
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -204,6 +214,15 @@ class CatalogState(
} finally {
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 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()
@@ -247,6 +296,12 @@ fun CatalogScreen(
}
val downloaded by app.state.downloadedFlow.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
LaunchedEffect(userId) {
@@ -255,12 +310,40 @@ fun CatalogScreen(
Column(modifier = Modifier.fillMaxSize()) {
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()) {
ScreenPlaceholder("загружаю…")
} else if (state.error != null && state.items.isEmpty()) {
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
} else if (state.items.isEmpty()) {
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
} else if (visibleItems.isEmpty()) {
ScreenPlaceholder(
when {
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
state.query.isNotBlank() -> "ничего не нашлось"
else -> "пусто"
},
)
} else {
LazyVerticalGrid(
state = gridState,
@@ -270,15 +353,20 @@ fun CatalogScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
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(
item = item,
posterUrl = posterUrlOrNull(app, item),
progress = watched[item.Id],
badge = mirrorBadge(
itemId = item.Id,
job = state.mirrors[item.Id],
downloaded = downloaded,
localDownloaded = localDownloaded,
glassesDownloading = videoProgress?.phase == DownloadPhase.DOWNLOADING,
glassesDownloadingPercent = videoProgress?.percent,
glassesDownloaded = item.Id in glassesDownloadedIds,
),
onClick = {
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(
item: JellyfinItem,
posterUrl: String?,
progress: WatchProgress?,
badge: MirrorBadge,
onClick: () -> Unit,
) {
// «Просмотрено»: % — когда известна длительность, иначе время; бар — снизу постера
val percent = progress?.watchedPercent()
val watchedLabel = progress?.takeIf { it.positionMs > 0L }
?.let { p -> percent?.let { "$it%" } ?: msToClock(p.positionMs) }
Column(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
@@ -392,6 +501,24 @@ private fun ItemCard(
.padding(bottom = 6.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(
modifier = Modifier
.padding(2.dp)
@@ -408,13 +535,26 @@ private fun ItemCard(
)
}
}
Text(
text = item.Name ?: item.Id,
color = PhoneTextDim,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
Row(
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,375 @@
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.Row
import androidx.compose.foundation.layout.Spacer
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.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.TextButton
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.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.unit.dp
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
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(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.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 ?: "сессия…",
color = PhoneAccent,
fontSize = 12.sp,
)
}
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s ->
DropdownMenuItem(
text = { Text(s.title, fontSize = 12.sp) },
onClick = {
activeId = s.id
app.selectChatSession(s.id)
sessionMenu = false
},
)
}
DropdownMenuItem(
text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) },
onClick = {
activeId = app.newChatSession()
sessionMenu = false
},
)
}
}
// Сброс текущей
TextButton(onClick = {
app.resetChatSession()
activeId?.let { id ->
val history = app.chatDao.messagesForSession(id).map(::uiMsgOf)
messages = history
}
}) {
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
}
}
Spacer(Modifier.height(2.dp))
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
val llmChoice by app.llmChoice.collectAsState()
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
color = PhoneMuted,
fontSize = 10.sp,
)
Spacer(Modifier.height(4.dp))
// Лента сообщений
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
// Ступенчатая смена сессии (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,
color = PhoneText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "в разработке (следующий этап)",
color = PhoneMuted,
fontSize = 13.sp,
modifier = Modifier.padding(top = 8.dp),
// Базовый шрифт сообщений (TZ-ui-four п.2): крупнее, чтобы читалось
// без напряжения (14.sp → 20.sp, ~+43%); вёртка баллонов не менялась.
fontSize = 20.sp,
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp)
.background(
color = if (isUser) PhoneSurface else PhoneCard,
shape = RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
/** Элемент ленты: 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 pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import java.util.Locale
/** Экраны приложения (простая навигация без nav-библиотеки). */
enum class Screen {
CATALOG,
DETAILS,
ACTIVITY,
GLASSES,
GLASSES_INFO,
CHAT,
@@ -45,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
private val bottomTabs = listOf(
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
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)
/**
* Статус зеркала элемента:
* Статус зеркала элемента (коротко для бейджа каталога):
* - ⟳ N% — конвертируется;
* - ↓ часть — что-то скачано локально (телефон) или на очки;
* - ✓ — зеркало готово;
* - ✗ — ошибка;
* - ✗ — ошибка конвертации;
* - ↓ N% — видео скачивается на очки;
* - ▶ — можно смотреть (видео уже на очках);
* - ✓ — зеркало готово (ещё не на очках);
* - ↓ часть — скачано что-то локально (телефон) или на очки;
* - нет — ничего нет.
*/
fun mirrorBadge(
@@ -88,6 +93,9 @@ fun mirrorBadge(
job: MirrorJob?,
downloaded: Map<String, String>,
localDownloaded: Set<String>,
glassesDownloading: Boolean = false,
glassesDownloadingPercent: Int? = null,
glassesDownloaded: Boolean = false,
): MirrorBadge {
val status = job?.status
val hasLocal = localDownloaded.contains(itemId)
@@ -101,6 +109,16 @@ fun mirrorBadge(
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
// Видео уже летит на очки — конкретнее «✓ готово»
glassesDownloading -> MirrorBadge(
"↓ ${glassesDownloadingPercent ?: 0}%",
PhoneBlue,
PhoneBlueText,
)
// Видео на очках — можно смотреть
glassesDownloaded -> MirrorBadge("▶", PhoneGreen, PhoneGreenText)
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
@@ -168,7 +186,7 @@ fun bytesToSize(bytes: Long?): String {
val gb = 1024.0 * 1024 * 1024
val mb = 1024.0 * 1024
return when {
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
else -> "%d КБ".format((bytes / 1024).toLong())
}
@@ -291,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
}
@Composable
fun SectionLabel(text: String) {
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
color = PhoneMuted,
@@ -299,6 +317,6 @@ fun SectionLabel(text: String) {
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp,
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.MirrorJob
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.phone.FileDownloadState
import pw.binom.viewmate.phone.PhoneApp
@@ -79,16 +78,16 @@ fun DetailsScreen(
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
// статус зеркала + дорожки; пока конвертируется — опрашиваем
// статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
LaunchedEffect(item.Id, refreshKey) {
loading = true
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
loading = false
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
delay(3_000)
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 очкам
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
if (alreadyPlaying) {
result = "включил (видео уже играет на очках)"
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
} else {
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) {
log("watch", "watch упал: ${e.message}")
snackbar.showSnackbar("watch упал: ${e.message}")
@@ -211,7 +221,7 @@ fun DetailsScreen(
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
} else {
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)
}
} else {
@@ -392,26 +402,28 @@ fun DetailsScreen(
keys.forEach { key ->
val name = finalFileName(key)
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
val isVideo = key == "video"
val phoneRaw = phoneFileState(finalFile, partFile)
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
} else {
phoneRaw.second
}
val glassesState = if (glassesFully) {
FileDownloadState.DONE to 100
} else {
glassesFileState(perFileProgress[name])
// Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
val state = when {
isVideo && glassesFully -> FileDownloadState.DONE to 100
isVideo -> glassesFileState(perFileProgress[name])
else -> phoneRaw.first to phonePercent
}
DownloadFileRow(
name = name,
sizeLabel = fileSizeOrProgress(
sizeBytes = knownSizeByName[name],
phone = phoneRaw.first to phonePercent,
glasses = glassesState,
),
phone = phoneRaw.first to phonePercent,
glasses = glassesState,
device = if (isVideo) "очки" else "телефон",
sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
"⏳ ${state.second}%"
} else {
bytesToSize(knownSizeByName[name])
},
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
private fun DownloadFileRow(
name: String,
device: String,
sizeLabel: String,
phone: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
state: Pair<FileDownloadState, Int>,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
@@ -523,13 +524,9 @@ private fun DownloadFileRow(
modifier = Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("телефон", color = PhoneMuted, fontSize = 9.sp)
Text(device, color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp))
FileChip(phone)
Spacer(modifier = Modifier.width(12.dp))
Text("очки", color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp))
FileChip(glasses)
FileChip(state)
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
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.sp
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
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
@@ -137,6 +141,10 @@ fun GlassesInfoScreen(
}
}
}
Spacer(modifier = Modifier.height(12.dp))
LlmModelSection()
}
}
@@ -165,3 +173,118 @@ private fun InfoRow(label: String, value: String) {
)
}
}
/**
* Блок выбора модели LLM ассистента: серверная (llm.binom.pw) или локальная
* Gemma 4 E2B (LiteRT-LM) + управление скачиванием модели (прогресс, отмена).
*/
@Composable
private fun LlmModelSection() {
val app = PhoneApp.instance
val choice by app.llmChoice.collectAsState()
val llmState by app.localLlmState.collectAsState()
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("Модель LLM ассистента")
StatusBlock {
Row(verticalAlignment = Alignment.CenterVertically) {
LlmChoiceChip(
label = "сервер",
selected = choice == LlmModelChoice.REMOTE,
onClick = { app.setLlmModelChoice(LlmModelChoice.REMOTE) },
)
Spacer(Modifier.width(8.dp))
LlmChoiceChip(
label = "локальная",
selected = choice == LlmModelChoice.LOCAL,
onClick = { app.setLlmModelChoice(LlmModelChoice.LOCAL) },
)
}
Spacer(Modifier.height(8.dp))
if (choice == LlmModelChoice.LOCAL) {
when (val s = llmState) {
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
}
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
ProgressBar(
percent = s.percent,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
}
Spacer(Modifier.height(4.dp))
Text(
text = "Скачивание модели… ${s.percent}%",
color = PhoneMuted,
fontSize = 10.sp,
)
}
LocalLlmModelState.Ready -> Text(
text = "Модель готова (Gemma 4 E2B, локально)",
color = PhoneGreen,
fontSize = 11.sp,
)
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Ошибка скачивания: ${s.message}",
color = PhoneRed,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
}
}
} else {
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
}
}
}
}
/** Чип переключателя выбора модели (активный — акцентный фон). */
@Composable
private fun LlmChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(if (selected) PhoneAccent else PhoneSurface)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = if (selected) PhoneBackground else PhoneTextDim,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
/** Маленькая текстовая кнопка (скачать/отмена/повтор) в блоке LLM. */
@Composable
private fun LlmSmallAction(label: String, onClick: () -> Unit) {
Text(
text = label,
color = PhoneAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
@@ -71,6 +71,9 @@ fun GlassesScreen(
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
var scale by remember { mutableStateOf(app.state.scale) }
// высота экрана очков (0..1): 1 — у верха, 0 — нижняя кромка у низа
var vertical by remember { mutableStateOf(app.state.vertical) }
val actions = app.actions
// активное скачивание на очки: первое с незавершённым файлом
@@ -183,6 +186,31 @@ fun GlassesScreen(
)
}
// высота экрана очков: 1.0 — у верха (как сейчас), 0.0 — нижняя кромка у низа (с учётом масштаба)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
) {
Text(
text = "Высота: ${"%.2f".format(vertical)}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
Slider(
value = vertical,
onValueChange = { vertical = it },
onValueChangeFinished = {
app.state.vertical = vertical
val a = actions
if (a != null) scope.launch { a.applyVertical(vertical) }
},
valueRange = 0f..1f,
steps = 9, // шаг 0.1
)
}
// активная загрузка контента на очки
if (activeGlassesItem != null) {
Text(
@@ -360,6 +388,9 @@ private fun GlassesStatusCard(
fg = if (playing) PhoneBlueText else PhoneMuted,
)
}
// Полевое испытание либы rayneo-mercury (BLE-протокол очков)
MercuryPanel()
}
}
@@ -0,0 +1,372 @@
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)
}
}
// ---- Яркость ----
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)
}
@@ -51,6 +51,8 @@ fun ViewMateApp() {
},
)
Screen.ACTIVITY -> ActivityScreen()
Screen.DETAILS -> {
val item = selected
if (item != null) {
@@ -12,58 +12,111 @@ import kotlin.test.assertTrue
class AudioSyncPlayerTest {
@Test
fun noSeekWhenWithinThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_800, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=800мс ≤ 1000мс — seek не нужен")
assertNull(d.playing, "play/pause совпадают — не трогаем")
}
// --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek ---
@Test
fun thresholdBoundaryDiff1000DoesNotSeek() {
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем")
}
@Test
fun seeksToVideoPositionWhenDiffAboveThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f, d.speed, "полное совпадение — темп не трогаем")
assertNull(d.seekToMs)
assertNull(d.playing)
}
@Test
fun seeksWhenAudioAheadOfVideo() {
val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
fun speedStaysFlatInsideTriggerBand() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_100, audioPlaying = true, videoPlaying = true)
assertEquals(1f, d.speed, "diff=100мс внутри гистерезиса [-150;+150] — дребезга нет")
assertNull(d.seekToMs)
assertNull(d.playing)
}
@Test
fun speedsUpWhenAudioLagsBehind() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_300, audioPlaying = true, videoPlaying = true)
assertEquals(1f + 300f * DRIFT_GAIN, d.speed, "звук отстаёт — догоняем ускорением ${1f + 300f * DRIFT_GAIN}")
assertNull(d.seekToMs, "diff=300мс внутри HARD-порога — seek не нужен")
assertNull(d.playing)
}
@Test
fun slowsDownWhenAudioAhead() {
val d = decideSync(audioPosition = 10_300, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f - 300f * DRIFT_GAIN, d.speed, "звук впереди — тормозим до ${1f - 300f * DRIFT_GAIN}")
assertNull(d.seekToMs)
}
@Test
fun smallDriftJustOutsideBandIsProportional() {
val d = decideSync(audioPosition = 10_151, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f - 151f * DRIFT_GAIN, d.speed, "diff=151мс (чуть за зоной) — слабый пропорциональный темп")
}
@Test
fun speedSaturatesAtMaxDrift() {
val behind = decideSync(audioPosition = 9_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f + MAX_DRIFT_SPEED, behind.speed, "diff=−1000мс → 1+0.2 упирается в потолок ${1f + MAX_DRIFT_SPEED}")
val ahead = decideSync(audioPosition = 11_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f - MAX_DRIFT_SPEED, ahead.speed, "diff=+1000мс → 1−0.2 упирается в потолок ${1f - MAX_DRIFT_SPEED}")
}
@Test
fun bandBoundaryExactly150MsStaysFlat() {
val ahead = decideSync(audioPosition = 10_150, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f, ahead.speed, "diff=+150мс — граница закрытой зоны, темп 1f")
val behind = decideSync(audioPosition = 9_850, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f, behind.speed, "diff=-150мс — граница закрытой зоны, темп 1f")
}
@Test
fun hardSeekWhenAudioFarBehind() {
val d = decideSync(audioPosition = 10_000, videoPosition = 16_000, audioPlaying = true, videoPlaying = true)
assertEquals(16_000L, d.seekToMs, "diff=6000мс > 5000мс — жёсткий seek на позицию очков")
assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко")
assertNull(d.playing)
}
@Test
fun hardSeekWhenAudioFarAhead() {
val d = decideSync(audioPosition = 26_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
assertEquals(20_000L, d.seekToMs)
assertEquals(1f, d.speed)
}
@Test
fun hardThresholdBoundaryDoesNotSeek() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=2000мс не БОЛЬШЕ порога — подгоняем скоростью, без рывка")
assertEquals(1f + MAX_DRIFT_SPEED, d.speed, "diff=2000мс → 1+0.4 — упирается в потолок ${1f + MAX_DRIFT_SPEED}")
}
@Test
fun hardThresholdPlusOneSeeks() {
val d = decideSync(audioPosition = 10_000, videoPosition = 15_001, audioPlaying = true, videoPlaying = true)
assertEquals(15_001L, d.seekToMs, "diff=5001мс > 5000мс — уже жёсткий seek")
}
@Test
fun playWhenVideoPlayingButAudioPaused() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true)
assertNull(d.seekToMs)
assertEquals(1f, d.speed, "play/pause влияние только на playing, не на скорость")
assertEquals(true, d.playing, "очки играют — аудио должно играть")
assertNull(d.seekToMs)
}
@Test
fun pauseWhenVideoPausedButAudioPlaying() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
assertNull(d.seekToMs)
assertEquals(1f, d.speed)
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
assertNull(d.seekToMs)
}
@Test
fun seekAndPlayApplyTogether() {
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true)
assertEquals(8_000L, d.seekToMs)
val d = decideSync(audioPosition = 5_000, videoPosition = 15_000, audioPlaying = false, videoPlaying = true)
assertEquals(15_000L, d.seekToMs)
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
}
@Test
fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs)
assertNull(d.playing)
assertEquals(1f, d.speed)
}
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.server.testing.testApplication
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
@@ -23,24 +22,49 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WavPcm
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
*/
class GlassesServerTest {
private val json = protocolJson
@Test
fun helloGetsWelcome() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
private fun startServer(): Pair<GlassesServer, Int> {
val port = freePort()
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
server.start()
return server to port
}
client.webSocket(GLASSES_WS_PATH) {
private fun freePort(): Int {
val s = java.net.ServerSocket(0)
val p = s.localPort
s.close()
return p
}
@Test
fun helloGetsWelcome() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
val frame = incoming.receive() as Frame.Text
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
@@ -50,12 +74,9 @@ class GlassesServerTest {
}
@Test
fun gestureIsProcessed() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun gestureIsProcessed() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
}
@@ -64,28 +85,22 @@ class GlassesServerTest {
}
@Test
fun connectedCounterTracksSessions() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) { }
fun connectedCounterTracksSessions() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
awaitTrue { hub.connected.value == 0 }
}
@Test
fun broadcastReachesAllGlasses() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
fun broadcastReachesAllGlasses() = runServer { port, hub ->
val client = wsClient()
coroutineScope {
val opened = Channel<Unit>(2)
val received = Channel<HostToGlasses>(2)
val jobs = (1..2).map {
launch {
client.webSocket(GLASSES_WS_PATH) {
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
opened.send(Unit)
while (true) {
val frame = incoming.receive()
@@ -115,12 +130,9 @@ class GlassesServerTest {
}
@Test
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
}
@@ -131,12 +143,9 @@ class GlassesServerTest {
}
@Test
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(
json.encodeToString(
GlassesToHost.serializer(),
@@ -163,6 +172,81 @@ class GlassesServerTest {
assertEquals("0.2.0", hub.appVersion.value)
}
// ---------- StopStt → SttCancel/SttDone (TASK-cancel-listen.md) ----------
/** StopStt(cancel=true) — двойной тап-отмена на очках: очки получают SttCancel(reason=cancel). */
@Test
fun stopSttCancelSendsSttCancelReasonCancel() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = true)))
val frame = incoming.receive() as Frame.Text
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
val cancel = assertIs<SttCancel>(msg)
assertEquals(SttCancel.REASON_CANCEL, cancel.reason)
}
}
/** StopStt(cancel=false) с пустым текстом — очки возвращаются в «слушаю»: SttCancel(reason=user). */
@Test
fun stopSttEmptyTextBroadcastsSttCancelReasonUser() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
val frame = incoming.receive() as Frame.Text
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
val cancel = assertIs<SttCancel>(msg)
assertEquals(SttCancel.REASON_USER, cancel.reason)
}
}
/**
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): весь принятый PCM
* распознаётся одним вызовом на буфер сессии, фейк-распознаватель
* даёт «привет» → очки получают SttDone с непустым текстом.
*/
@Test
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
hub.sttFactory = {
SttStreamer(
stt = FakePhraseRecognizer(),
onSilence30s = { },
onAutoFinished = { },
)
}
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
// кидать по 3200 байт (100 мс @ 16 кГц s16le mono)
var off = 0
while (off < pcm.size) {
val end = minOf(off + 3200, pcm.size)
send(json.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end))))
off = end
}
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
val frame = incoming.receive() as Frame.Text
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
val done = assertIs<SttDone>(msg)
assertTrue(done.full.isNotBlank())
}
}
// ---------- помощники ----------
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
val (server, port) = startServer()
try {
kotlinx.coroutines.runBlocking { block(port, server.hub) }
} finally {
server.stop()
}
}
private fun wsClient() = io.ktor.client.HttpClient {
install(WebSockets)
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {
@@ -0,0 +1,164 @@
package pw.binom.viewmate.phone
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** OpenAI-HTTP-сервер (Ktor): /v1/chat/completions (stream=false|true), маппинг ролей, ошибки. */
class OpenAiHttpServerTest {
private val json = Json
private class FakeLlm(
private val text: String = "Привет, мир!",
private val failure: String? = null,
) : LlmClient {
var calls = 0
var lastSystem: String? = null
var lastMessages: List<ChatMessage> = emptyList()
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
calls++
lastSystem = system
lastMessages = messages
failure?.let { throw IllegalStateException(it) }
return text
}
}
private val chatBody =
"""{"model":"any","stream":false,"messages":[{"role":"user","content":"Привет"}]}"""
private fun call(body: String, llm: LlmClient?): OpenAiHttpServer.ChatOutcome =
runBlocking { OpenAiHttpServer.handleChat(llm, body) }
@Test
fun nonStream_returnsCompletionJson() {
val llm = FakeLlm()
val outcome = call(chatBody, llm) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(200, outcome.code)
val body = outcome.body
assertTrue(body.contains("\"object\":\"chat.completion\""))
assertTrue(body.contains("\"content\":\"Привет, мир!\""))
assertTrue(body.contains("\"finish_reason\":\"stop\""))
assertTrue(body.contains("\"id\":\"chatcmpl-"))
assertEquals(1, llm.calls)
assertEquals(listOf(ChatMessage.of(ChatRole.USER, "Привет")), llm.lastMessages)
assertEquals(null, llm.lastSystem)
}
@Test
fun systemAndDeveloperRoles_goToSystem() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"system","content":"S1"},{"role":"developer","content":"S2"},{"role":"user","content":"U"},{"role":"assistant","content":"A"}]}""",
llm,
)
assertEquals("S1\nS2", llm.lastSystem)
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), llm.lastMessages.map { it.role })
}
@Test
fun toolRole_mapsToToolMessage() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"user","content":"U"},{"role":"tool","tool_call_id":"t1","content":"результат"}]}""",
llm,
)
val tool = llm.lastMessages.last()
assertEquals(ChatRole.TOOL, tool.role)
assertEquals("t1", tool.toolCallId)
}
@Test
fun contentAsArrayOfTextParts_joined() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"user","content":[{"type":"text","text":"часть1"},{"type":"text","text":"часть2"}]}]}""",
llm,
)
assertEquals("часть1\nчасть2", llm.lastMessages.last().content)
}
@Test
fun assistantNullContentWithToolCalls_decodes() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"user","content":"U"},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}]}""",
llm,
)
val roles = llm.lastMessages.map { it.role }
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), roles)
}
@Test
fun stream_emitsSseChunksAndDone() {
val llm = FakeLlm()
val outcome = call(
"""{"model":"any","stream":true,"messages":[{"role":"user","content":"Привет"}]}""",
llm,
) as OpenAiHttpServer.ChatOutcome.Sse
val data = outcome.frames
assertTrue(data.any { it.endsWith("[DONE]\n\n") || it == "data: [DONE]\n\n" })
val chunks = data.filter { !it.contains("[DONE]") }.map {
json.decodeFromString(JsonElement.serializer(), it.removePrefix("data: ").trim())
}
val choices = chunks.map { chunk ->
chunk.jsonObject["choices"]!!.jsonArray[0].jsonObject
}
val deltas = choices.map { it["delta"]!!.jsonObject }
assertEquals("assistant", deltas.first()["role"]!!.jsonPrimitive.content)
val full = deltas.mapNotNull { it["content"]?.jsonPrimitive?.content }.joinToString("")
assertEquals("Привет, мир!", full)
assertEquals("stop", choices.last()["finish_reason"]!!.jsonPrimitive.content)
assertTrue(chunks.all { it.jsonObject["object"]!!.jsonPrimitive.content == "chat.completion.chunk" })
}
@Test
fun invalidBody_400() {
val outcome = call("не json", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(400, outcome.code)
assertTrue(outcome.body.contains("\"error\""))
}
@Test
fun emptyMessages_400() {
val outcome = call("""{"messages":[]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(400, outcome.code)
}
@Test
fun unknownRole_400() {
val outcome = call("""{"messages":[{"role":"alien","content":"hi"}]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(400, outcome.code)
assertTrue(outcome.body.contains("\"error\""))
}
@Test
fun noLlm_503() {
val outcome = call(chatBody, null) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(503, outcome.code)
assertTrue(outcome.body.contains("\"error\""))
}
@Test
fun llmFailure_502() {
val outcome = call(chatBody, FakeLlm(failure = "boom")) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(502, outcome.code)
assertTrue(outcome.body.contains("boom"))
}
@Test
fun allIps_containsLoopback() {
assertTrue(OpenAiHttpServer.allIps().contains("127.0.0.1"))
}
}
@@ -0,0 +1,95 @@
package pw.binom.viewmate.phone
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class WatchProgressStoreTest {
private fun tempDir(name: String): File = createTempDirectory(name).toFile()
private fun newStore(): Pair<WatchProgressStore, File> {
val dir = tempDir("watch-progress")
return WatchProgressStore(dir) to dir
}
@Test
fun `upsert and resume position`() {
val (store, _) = newStore()
store.upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
assertEquals(3_600_000L, store.resumePosition("item-1"))
assertEquals(50, store.watchedPercent("item-1"))
assertNull(store.resumePosition("other"))
}
@Test
fun `resume returns null for short or finished viewing`() {
val (store, _) = newStore()
store.upsert("short", positionMs = 10_000L, durationMs = 7_200_000L)
assertNull(store.resumePosition("short"))
val finishedMs = (7_200_000L * WatchProgressStore.FINISHED_RATIO).toLong() + 1000L
store.upsert("finished", positionMs = finishedMs, durationMs = 7_200_000L)
assertNull(store.resumePosition("finished"))
val almost = 7_200_000L * 0.90
store.upsert("almost", positionMs = almost.toLong(), durationMs = 7_200_000L)
assertNotNull(store.resumePosition("almost"))
}
@Test
fun `backward and duplicate positions are not written`() {
val (store, dir) = newStore()
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
val writtenAfterAdvance = File(dir, "watchprogress.json").readText()
store.upsert("item-1", positionMs = 55_000L, durationMs = 7_200_000L)
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
assertEquals(writtenAfterAdvance, File(dir, "watchprogress.json").readText())
}
@Test
fun `position beyond duration is rejected`() {
val (store, dir) = newStore()
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
val before = File(dir, "watchprogress.json").readText()
store.upsert("item-1", positionMs = 99_000_000L, durationMs = 7_200_000L)
assertEquals(before, File(dir, "watchprogress.json").readText())
assertEquals(60_000L, store.resumePosition("item-1"))
}
@Test
fun `duration discovered later is persisted`() {
val (store, dir) = newStore()
store.upsert("item-1", positionMs = 60_000L)
assertNull(store.watchedPercent("item-1"))
store.upsert("item-1", positionMs = 60_000L, durationMs = 120_000L)
assertEquals(50, store.watchedPercent("item-1"))
assertEquals("watchprogress.json", File(dir, "watchprogress.json").name)
}
@Test
fun `progress survives reload`() {
val dir = tempDir("watch-progress-reload")
WatchProgressStore(dir).upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
val reloaded = WatchProgressStore(dir)
assertEquals(3_600_000L, reloaded.resumePosition("item-1"))
assertEquals(50, reloaded.watchedPercent("item-1"))
assertEquals(mapOf("item-1" to WatchProgress(3_600_000L, 7_200_000L)), reloaded.flow.value)
}
@Test
fun `unknown duration gives percent null but resume works`() {
val (store, _) = newStore()
store.upsert("item-1", positionMs = 3_600_000L)
assertNull(store.watchedPercent("item-1"))
assertEquals(3_600_000L, store.resumePosition("item-1"))
}
}
@@ -0,0 +1,308 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Механика агента: три состояния промпта, бесшовная подмена, валидация
* аргументов, невидимые тулы, таймаут (инжект часов), по-сессийность.
*/
class AgentToolkitTest {
private var now = 10_000_000L
private class Fixture(val toolkit: AgentToolkit, val calls: MutableList<String>, val shown: MutableList<String>)
private fun fixture(withExecutor: Boolean = true): Fixture {
now = 10_000_000L
val calls = mutableListOf<String>()
val shown = mutableListOf<String>()
val registry = ToolsetRegistry(timeoutMs = ToolsetRegistry.DEFAULT_TIMEOUT_MS, clock = { now })
val ts = Toolset(
name = "media",
description = "Управление медиа и воспроизведением (search/info/play)",
tools = listOf(
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
ToolSignature("info", "Детали фильма.", args = listOf("id")),
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
),
)
if (withExecutor) {
registry.register(ts) { tset, tool, args ->
calls.add("$tset.$tool ${args.entries.joinToString { "${'{' }${it.key}=${it.value}${'}'}" }}")
"Результат $tool: ${args.entries.joinToString { "${it.key}=${it.value}" }}"
}
} else {
registry.register(ts, null)
}
val skills = SkillRegistry(
listOf(
Skill("media", "Поиск фильмов, детали, запуск/управление.", "ТЕЛО: media_search|query=..."),
Skill("web", "Как сделать веб-поиск.", "Шаги веб-поиска..."),
),
)
return Fixture(
toolkit = AgentToolkit(skills = skills, toolsets = registry, showText = { shown.add(it) }),
calls = calls,
shown = shown,
)
}
private fun run(d: AgentToolkit.Dispatch): String =
(d as AgentToolkit.Dispatch.Run).result
// ---------- три состояния промпта ----------
@Test
fun `состояние 1 нет активных тулсетов, каталог и скиллы`() {
val p = fixture().toolkit.systemPrompt(1)
assertTrue(p.contains("Ядро:"))
assertTrue(p.contains("get_current_time"))
assertTrue(p.contains("show_text|text"))
assertTrue(p.contains("enable_toolset|имя"))
assertTrue(p.contains("Каталог тулсетов"))
assertTrue(p.contains("media — Управление медиа и воспроизведением"))
// скиллы: по строке на каждый
assertTrue(p.contains("media — Поиск фильмов, детали, запуск/управление."))
assertTrue(p.contains("web — Как сделать веб-поиск."))
// описания тул набора ещё не раскрыты (набор не активен)
assertFalse(p.contains("Активные тулсеты"))
assertFalse(p.contains("media_search|query"))
}
@Test
fun `enable невидимый, промпт пересобирается, состояние 3`() {
val f = fixture()
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(d is AgentToolkit.Dispatch.Silent)
val p = f.toolkit.systemPrompt(1)
assertTrue(p.contains("Активные тулсеты: media"))
assertTrue(p.contains("media_search|query — Поиск фильмов"))
assertTrue(p.contains("media_play|id,seek_ms? — Запуск/управление"))
// связанный скилл убран из промпта (имя скилла = имя тулсета)
assertFalse(p.contains("media — Поиск фильмов"))
// остальные скиллы на месте
assertTrue(p.contains("web — Как сделать веб-поиск."))
}
@Test
fun `повторный enable, напоминание, уже активен`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val d2 = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
val p1 = f.toolkit.systemPrompt(1)
assertTrue(p1.contains("Тулсет \"media\" уже активен, таймаут продлён"))
// напоминание одноразовое: следующий промпт без него
val p2 = f.toolkit.systemPrompt(1)
assertFalse(p2.contains("уже активен"))
}
@Test
fun `disable обратно в состояние 1, двойной disable не ошибка`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
val p1 = f.toolkit.systemPrompt(1)
assertFalse(p1.contains("Активные тулсеты"))
assertTrue(p1.contains("media — Поиск фильмов")) // скилл вернулся в индекс
val d2 = f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
val p2 = f.toolkit.systemPrompt(1)
assertTrue(p2.contains("Нет активных тулсетов"))
}
@Test
fun `неизвестный набор, напоминание с доступными в промпте`() {
val f = fixture()
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "other"))
assertTrue(d is AgentToolkit.Dispatch.Silent)
val p = f.toolkit.systemPrompt(1)
assertTrue(p.contains("Набора \"other\" нет"))
assertTrue(p.contains("media"))
}
// ---------- бесшовная подмена ----------
@Test
fun `подмена, аргументы не совпадают, вызов явно не выполнялся`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
assertTrue(msg.contains("Тулсет media активирован"))
assertTrue(msg.contains("Реестр:"))
assertTrue(msg.contains("media_search"))
assertTrue(msg.contains("ещё НЕ выполнялся"))
assertTrue(msg.contains("вызови media_search с аргументами: query"))
// тул не выполнялся реально
assertTrue(f.calls.isEmpty())
// набор стал активен (подмена = enable)
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
}
@Test
fun `подмена, валидные аргументы, выполнение без второго круга`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "Рассел")))
assertTrue(msg.contains("Результат media_search"))
assertEquals(1, f.calls.size)
assertTrue(f.calls.single().contains("media.media_search"))
assertTrue(f.calls.single().contains("query=Рассел"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
}
// ---------- валидация и маршрутизация ----------
@Test
fun `активный тулсет, не хватает аргументов, ошибка со схемой`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
assertTrue(msg.contains("аргументы неверны"))
assertTrue(msg.contains("query"))
assertTrue(msg.contains("Схема: media_search|query"))
}
@Test
fun `маршрутизация по префкису в набор, вызов идёт в executor`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_info", mapOf("id" to "123")))
assertTrue(msg.contains("Результат media_info"))
assertTrue(f.calls.single().contains("media.media_info"))
assertTrue(f.calls.single().contains("id=123"))
}
@Test
fun `тул не входит в сигнатуры набора`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_bogus", emptyMap()))
assertTrue(msg.contains("Тул \"media_bogus\" в тулсете \"media\" нет"))
}
@Test
fun `неизвестный тул, ответ со списком доступных`() {
val msg = run(fixture().toolkit.dispatch(1, "frobo", emptyMap()))
assertTrue(msg.contains("Тул \"frobo\" не найден"))
assertTrue(msg.contains("enable_toolset"))
assertTrue(msg.contains("media"))
}
@Test
fun `активный тулсет без исполнителя, понятное сообщение`() {
val f = fixture(withExecutor = false)
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "x")))
assertTrue(msg.contains("ещё не реализованы"))
}
// ---------- ядро тулов ----------
@Test
fun `read_skill, весь текст скилла`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "media")))
assertTrue(msg.contains("ТЕЛО: media_search|query=..."))
}
@Test
fun `read_skill неизвестный скилл, прощающая подсказка`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "неттакого")))
assertTrue(msg.contains("Скилл \"неттакого\" не найден"))
assertTrue(msg.contains("media"))
assertTrue(msg.contains("web"))
}
@Test
fun `show_text, текст шлётся в шир, результат и пропуск без дисплея`() {
val f = fixture()
val msg = run(f.toolkit.dispatch(1, "show_text", mapOf("text" to "Привет!")))
assertEquals("Привет!", f.shown.single())
assertTrue(msg.contains("Показано на очках: Привет!"))
val bad = run(f.toolkit.dispatch(1, "show_text", emptyMap()))
assertTrue(bad.contains("\"text\""))
// без подключённого дисплея (showText = null) — понятный результат, не падение
val bare = AgentToolkit(SkillRegistry(emptyList()), ToolsetRegistry(clock = { now }))
val disconnected = run(bare.dispatch(1, "show_text", mapOf("text" to "X")))
assertTrue(disconnected.contains("не показан"))
}
@Test
fun `get_current_time, время в ISO`() {
val msg = run(fixture().toolkit.dispatch(1, "get_current_time", emptyMap()))
val ts = Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")
assertTrue(ts.find(msg) != null, "не ISO 8601 в: $msg")
}
// ---------- таймаут и сессии ----------
@Test
fun `истёкший тулсет исчезает из промпта, скилл возвращается`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
// инжект часов: проскакиваем таймаут без реального ожидания 10 минут
now += ToolsetRegistry.DEFAULT_TIMEOUT_MS + 1_000L
val p = f.toolkit.systemPrompt(1)
assertFalse(p.contains("Активные тулсеты"))
assertFalse(p.contains("media_search|query"))
assertTrue(p.contains("media — Поиск фильмов")) // скилл снова в индексе
}
@Test
fun `активность по сессиям, разные сессии независимы`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
// в сессии 2 вызов тула неактивного набора — подмена активирует только её
val msg = run(f.toolkit.dispatch(2, "media_search", mapOf("query" to "X")))
assertTrue(msg.contains("Результат media_search"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
assertTrue(f.toolkit.systemPrompt(2).contains("Активные тулсеты: media"))
}
@Test
fun `disable в сессии 2 не трогает активный в сессии 1`() {
val f = fixture()
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(2, "enable_toolset", mapOf("name" to "media"))
f.toolkit.dispatch(2, "disable_toolset", mapOf("name" to "media"))
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
}
// ---------- парсинг аргументов ----------
@Test
fun `парсинг пар и голых значений, прощающий к лишнему`() {
val tk = fixture().toolkit
assertEquals(mapOf("name" to "media"), tk.parseArgs("media", listOf("name")))
assertEquals(emptyMap<String, String>(), tk.parseArgs(null, listOf("name")))
assertEquals(emptyMap<String, String>(), tk.parseArgs(" ", listOf("name")))
assertEquals(mapOf("query" to "Расскл"), tk.parseArgs("query=Расскл", listOf("query")))
assertEquals(
mapOf("id" to "12", "seek_ms" to "300"),
tk.parseArgs("id=12,seek_ms=300", listOf("id", "seek_ms?")),
)
// «голое» значение — первому обязательному аргументу
assertEquals(mapOf("id" to "abc"), tk.parseArgs("abc", listOf("id", "seek_ms?")))
// пар через «&» и лишние ключи — не ошибка
val m = tk.parseArgs("query=x&extra=5", listOf("query"))
assertEquals("x", m["query"])
assertTrue(m.containsKey("extra"))
}
@Test
fun `схема аргументов, schemaFor, у тулов`() {
val tk = fixture().toolkit
assertEquals(emptyList(), tk.schemaFor("get_current_time"))
assertEquals(listOf("text"), tk.schemaFor("show_text"))
assertEquals(listOf("name"), tk.schemaFor("enable_toolset"))
assertEquals(listOf("name"), tk.schemaFor("read_skill"))
assertEquals(listOf("id", "seek_ms?"), tk.schemaFor("media_play"))
assertEquals(emptyList(), tk.schemaFor("unknown_tool"))
}
}
@@ -0,0 +1,38 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Тесты тулов ассистента — чистая логика без БД и Android.
*/
class AgentToolsTest {
@Test
fun `тул времени возвращает ISO 8601 с датой и временем`() {
val result = TimeTool.invoke("{}")
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
assertTrue(
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
"не ISO 8601: $result",
)
assertTrue(result.startsWith("20"), "год не 20xx: $result")
}
@Test
fun `реестр содержит тулу времени с описанием`() {
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
val desc = AgentTools.describeAll()
assertTrue(desc.contains("get_current_time"))
assertTrue(desc.contains("ISO 8601"))
}
@Test
fun `тула времени не зависит от аргументов`() {
// Пустой JSON и мусор — один результат (тула без параметров)
val a = TimeTool.invoke("{}")
val b = TimeTool.invoke("{\"x\":1}")
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
}
}
@@ -0,0 +1,125 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Тесты тул-канала на границе клиента: парсер текстового маркера
* (remote) и OpenAI-JSON-описатель тула (local, нативный LiteRT-канал).
*/
class LlmClientTest {
// ---------- parseToolCallArgs (текстовый протокол) ----------
@Test
fun parseArgsEmptyWhenNoArgs() {
assertEquals(emptyMap<String, String>(), parseToolCallArgs(null, emptyList()))
assertEquals(emptyMap<String, String>(), parseToolCallArgs("", emptyList()))
}
@Test
fun parseArgsNamedAndBare() {
val args = parseToolCallArgs("query=old songs,seek_ms=30000", listOf("query", "seek_ms?"))
assertEquals("old songs", args["query"])
assertEquals("30000", args["seek_ms"])
}
@Test
fun parseArgsBareValueGoesToFirstExpected() {
val args = parseToolCallArgs("old songs", listOf("query"))
assertEquals("old songs", args["query"])
}
@Test
fun parseArgsDuplicateNameLastWins() {
val args = parseToolCallArgs("id=1,id=2", listOf("id"))
assertEquals("2", args["id"])
}
@Test
fun parseArgsUnknownNamePassedThrough() {
val args = parseToolCallArgs("id=1,foo=bar", listOf("id"))
assertEquals("1", args["id"])
assertTrue(args.containsKey("foo"))
}
@Test
fun parseArgsQuotedValueKeepsQuotes() {
// Парсер прощающий: кавычки — литеральные символы, пары режутся запятой
val args = parseToolCallArgs("""a="hello world",b=1""", listOf("a", "b"))
assertEquals("\"hello world\"", args["a"])
assertEquals("1", args["b"])
}
@Test
fun parseArgsUnknownFormatGoesToFirstRequired() {
// Неизвестный формат (без `=`) не падает: всё сырое значение
// уходит первому обязательному аргументу
val args = parseToolCallArgs("""{"id":"c42"}""", listOf("id"))
assertEquals("""{"id":"c42"}""", args["id"])
}
// ---------- TOOL_CALL-маркер (текстовый протокол, remote) ----------
@Test
fun markerFoundWithArgs() {
val m = TOOL_CALL_RE.find("хорошо, TOOL_CALL: media_search|query=old songs,seek_ms=30000")
assertNotNull(m)
assertEquals("media_search", m.groupValues[1])
assertEquals("query=old songs,seek_ms=30000", m.groupValues[2].trim())
}
@Test
fun markerFoundWithoutArgs() {
val m = TOOL_CALL_RE.find("TOOL_CALL: get_current_time")
assertNotNull(m)
assertEquals("get_current_time", m.groupValues[1])
assertTrue(m.groupValues[2].isEmpty())
}
@Test
fun markerNotFoundInPlainText() {
assertNull(TOOL_CALL_RE.find("Какое сейчас время?"))
}
// ---------- ToolSpec ----------
@Test
fun specArgNamesAndRequired() {
val spec = ToolSpec("media_search", "поиск", listOf("query", "seek_ms?"))
assertEquals(listOf("query", "seek_ms"), spec.argNames())
assertEquals(listOf("query"), spec.requiredArgs())
}
// ---------- toolJson (нативный LiteRT-канал) ----------
@Test
fun toolJsonHasFunctionFields() {
val json = toolJson(ToolSpec("media_search", "поиск трека", listOf("query", "seek_ms?")))
val obj = Json.parseToJsonElement(json) as JsonObject
assertEquals("media_search", (obj["name"] as JsonPrimitive).content)
val params = obj["parameters"] as JsonObject
val props = params["properties"] as JsonObject
assertTrue(props.containsKey("query"))
assertTrue(props.containsKey("seek_ms"))
val required = params["required"] as JsonArray
assertEquals(listOf("query"), required.map { (it as JsonPrimitive).content })
}
@Test
fun toolJsonWithoutArgsHasNoProperties() {
val json = toolJson(ToolSpec("get_current_time", "текущее время"))
val obj = Json.parseToJsonElement(json) as JsonObject
val params = obj["parameters"] as JsonObject
assertFalse(params.containsKey("properties"))
assertFalse(params.containsKey("required"))
}
}

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