Compare commits
48 Commits
1
...
da8182443c
| Author | SHA1 | Date | |
|---|---|---|---|
| da8182443c | |||
| 17385d1784 | |||
| 98ac84ffe8 | |||
| ac56e70283 | |||
| 4e60e11d24 | |||
| b0baa19e7d | |||
| d34d15ecef | |||
| 19fe1b34bc | |||
| 1009713a28 | |||
| 0f235da0ed | |||
| 74701a2108 | |||
| 4f4a551c91 | |||
| 80236902ba | |||
| d113ffd744 | |||
| 8958370507 | |||
| 883fd8e5e4 | |||
| 6d22f56bd8 | |||
| b17b5bf424 | |||
| d78ed4429e | |||
| 032424f513 | |||
| 4556d3b6e4 | |||
| b40e44c653 | |||
| d674b31a81 | |||
| c222a4ac76 | |||
| e21c27b0ee | |||
| 0d79b5aa33 | |||
| 40edd88c1d | |||
| fc84ba16b6 | |||
| c940a7e0eb | |||
| 22eb83fd17 | |||
| c1c169036a | |||
| 7557d7229f | |||
| 504e66f0c6 | |||
| e24caaa684 | |||
| eaca47c443 | |||
| 706cc10d1e | |||
| 51049d56a8 | |||
| ce2d9c5448 | |||
| d585ddf576 | |||
| 75496dd35d | |||
| 7527cf8848 | |||
| 2d6ca1e097 | |||
| f75e672819 | |||
| f36d04adc1 | |||
| 28d0f9b68d | |||
| c5cbc893ac | |||
| 22369670cb | |||
| bbf7a020d4 |
@@ -0,0 +1,3 @@
|
|||||||
|
# >>> cortexkit:magic-context
|
||||||
|
magic-context/
|
||||||
|
# <<< cortexkit:magic-context
|
||||||
@@ -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 — невидимые тулы (вызовы не в истории)
|
||||||
|
- Скилл исчезает из промпта при активном наборе
|
||||||
|
- История не вычищается
|
||||||
|
- Подмена вызова на активацию — с явной пометкой «вызов ещё не выполнен»
|
||||||
@@ -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) —
|
||||||
|
«убожество» воспроизводимо в оффлайне?
|
||||||
@@ -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-ом (не агента).
|
||||||
@@ -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 → ответ на очки.
|
||||||
@@ -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 или запасной), что осталось проверить на железе.
|
||||||
@@ -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. Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу.
|
||||||
@@ -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.
|
||||||
|
- В конце — краткий отчёт: файлы, что изменено, результат сборки/тестов.
|
||||||
@@ -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 на нашем устройстве, скачивается ли модель).
|
||||||
@@ -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 на одинаковых запросах
|
||||||
@@ -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, что осталось непокрыто тестами.
|
||||||
@@ -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-запрос → ответ на очки.
|
||||||
@@ -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 — собирается
|
||||||
@@ -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` чистый, коммит с реализацией и этим ТЗ есть.
|
||||||
@@ -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 + полевая приёмка на железе.
|
||||||
@@ -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.
|
||||||
|
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
|
||||||
@@ -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 (скролл уже починен).
|
||||||
|
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
|
||||||
@@ -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`, прогнать тесты, показать результат.
|
||||||
@@ -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` (батч) не трогаем вообще.
|
||||||
@@ -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 НЕ делать.
|
||||||
@@ -1,106 +1,132 @@
|
|||||||
# Задача: масштаб на всех экранах очков + сохранение скролла каталога
|
# Задача: телефон не должен засыпать во время просмотра (foreground service)
|
||||||
|
|
||||||
## Баг 1: ConnectionScreen игнорирует масштаб
|
## Симптом (пользователь, 21.08)
|
||||||
|
|
||||||
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено)
|
«Если я телефон отправлю спать, то звук на телефоне прекращается. А когда на
|
||||||
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется —
|
телефоне прекращается звук, очки тоже перестают играть.»
|
||||||
сразу после старта масштаб 1».
|
|
||||||
|
|
||||||
**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в
|
## Корень (диагноз подтверждён)
|
||||||
`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` +
|
|
||||||
offset к центру). А `ConnectionScreen()` в
|
|
||||||
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354)
|
|
||||||
рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) —
|
|
||||||
scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть,
|
|
||||||
но на не-видео экране он не виден.
|
|
||||||
|
|
||||||
**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen:
|
Архитектура: звук играет ТОЛЬКО на телефоне (у очков нет динамиков), видео —
|
||||||
|
на очках, синк по позиции (очки — мастер, 5с-цикл). На телефоне НЕТ
|
||||||
|
foreground service и НЕТ wake/wifi lock — приложение живёт только в Activity
|
||||||
|
(`app-phone/src/main/AndroidManifest.xml`: только MainActivity, пермишенов
|
||||||
|
WAKE_LOCK нет). При выключении экрана HONOR (Android 16, агрессивная фоновая
|
||||||
|
политика) замораживает/убивает процесс:
|
||||||
|
|
||||||
```kotlin
|
1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
|
||||||
fun ConnectionScreen() {
|
автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
|
||||||
val connection = GlassesApp.instance.hostConnection
|
2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
|
||||||
val connected by connection.connected.collectAsState()
|
|
||||||
val status by connection.status.collectAsState()
|
|
||||||
val messages by connection.messages.collectAsState()
|
|
||||||
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
|
|
||||||
|
|
||||||
Binocular(scale = false) {
|
## Решение
|
||||||
val screenWidthPx = BinocularScreenSize.current.width
|
|
||||||
val screenHeightPx = BinocularScreenSize.current.height
|
Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK +
|
||||||
// масштаб через layout — как в GlassesMovieScreen
|
WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране
|
||||||
val containerW = (screenWidthPx * scale).toInt()
|
телефона. Очки тогда не теряют связь, видео не паузится, звук играет.
|
||||||
val containerH = (screenHeightPx * scale).toInt()
|
|
||||||
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
|
### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`)
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
```xml
|
||||||
.width(containerW.px)
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
.height(containerH.px)
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
.offset(x = offsetX.px)
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
.background(Color.Black),
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
) {
|
```
|
||||||
... существующий контент ...
|
Внутри `<application>` — сервис:
|
||||||
}
|
```xml
|
||||||
}
|
<service
|
||||||
}
|
android:name=".PlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
```
|
```
|
||||||
|
|
||||||
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
|
### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
|
||||||
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
|
|
||||||
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
|
|
||||||
|
|
||||||
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
|
|
||||||
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
|
|
||||||
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
|
|
||||||
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
|
|
||||||
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
|
|
||||||
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
|
|
||||||
там есть `Side.LEFT/RIGHT` через CompositionLocal).
|
|
||||||
|
|
||||||
## Баг 2: скролл каталога теряется при навигации
|
|
||||||
|
|
||||||
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
|
|
||||||
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
|
|
||||||
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
|
|
||||||
папку, потом ещё — и т.д.»
|
|
||||||
|
|
||||||
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
|
|
||||||
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
|
|
||||||
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
|
|
||||||
|
|
||||||
**Фикс:** добавить в `CatalogState`:
|
|
||||||
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
|
|
||||||
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
|
|
||||||
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
|
|
||||||
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
|
|
||||||
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
|
|
||||||
|
|
||||||
В `CatalogScreen` composable:
|
|
||||||
```kotlin
|
```kotlin
|
||||||
val gridState = rememberLazyGridState()
|
class PlaybackService : Service() {
|
||||||
// восстанавливаем позицию при смене пути/загрузке
|
companion object {
|
||||||
LaunchedEffect(state.path, state.items) {
|
const val CHANNEL_ID = "viewmate_playback"
|
||||||
val (idx, off) = state.savedScrollForCurrentPath()
|
const val NOTIFICATION_ID = 1
|
||||||
gridState.scrollToItem(idx, off)
|
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
|
||||||
}
|
fun start(context: Context) {
|
||||||
// сохраняем позицию при скролле
|
ContextCompat.startForegroundService(
|
||||||
LaunchedEffect(gridState) {
|
context, Intent(context, PlaybackService::class.java)
|
||||||
snapshotFlow {
|
)
|
||||||
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
|
}
|
||||||
}.collect { (idx, off) -> state.setScroll(idx, off) }
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, PlaybackService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// канал уведомлений (Android 8+)
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "ViewMate playback",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) }
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle("ViewMate")
|
||||||
|
.setContentText("Звук на телефоне при просмотре на очках")
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
// держим CPU и WiFi живыми при выключенном экране
|
||||||
|
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
|
||||||
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
|
||||||
|
?.apply { acquire() }
|
||||||
|
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
|
||||||
|
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
|
||||||
|
?.apply { acquire() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||||
|
START_NOT_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
wifiLock?.takeIf { it.isHeld }?.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
и `LazyVerticalGrid(state = gridState, ...)`.
|
Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
|
||||||
|
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
|
||||||
|
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
|
||||||
|
|
||||||
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
|
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
|
||||||
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
|
|
||||||
каждый уровень сохраняет свою позицию.
|
|
||||||
|
|
||||||
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
|
- **Старт — при каждом старте аудио** (3 места):
|
||||||
`androidx.compose.runtime.snapshotFlow`.
|
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
|
||||||
|
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
|
||||||
|
(строка ~127) и второй play (строка ~215)
|
||||||
|
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
|
||||||
|
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
|
||||||
|
приложение живо) — НО только если пользователь не против постоянного
|
||||||
|
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
|
||||||
|
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
|
||||||
|
уведомление — постоянный индикатор «ViewMate работает». Это проще и
|
||||||
|
покрывает и watch, и autostartAudio, и STT-сессии.
|
||||||
|
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
|
||||||
|
|
||||||
## Критерии приёмки
|
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
|
||||||
|
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
|
||||||
|
приложения-компаньона — так работает, например, в паре с очками).
|
||||||
|
|
||||||
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
|
### 4. Проверка
|
||||||
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
|
|
||||||
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
|
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
|
||||||
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
|
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
|
||||||
|
3. На железе (через 142): установить APK, запустить, начать просмотр,
|
||||||
|
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
|
||||||
|
проверить: звук играет, видео на очках играет (позиция растёт в логах),
|
||||||
|
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ dependencies {
|
|||||||
implementation(project(":lib-core"))
|
implementation(project(":lib-core"))
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
|
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
|
||||||
|
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
|
||||||
|
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
|
||||||
|
// implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
|
||||||
|
|
||||||
// WS-клиент очков (OkHttp — Android-движок)
|
// WS-клиент очков (OkHttp — Android-движок)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<!-- Bluetooth SPP-транспорт (включается флагом GlassesConfig.USE_BLUETOOTH):
|
||||||
|
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
|
||||||
|
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".GlassesApp"
|
android:name=".GlassesApp"
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package pw.binom.viewmate.glasses
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.viewmate.core.log
|
||||||
|
import pw.binom.viewmate.core.net.BtTransportConstants
|
||||||
|
import pw.binom.viewmate.core.net.GlassesTransport
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bluetooth-транспорт, сторона очков («клиент») — TASK-transport.md п.3:
|
||||||
|
* RFCOMM/SPP-клиент ([BtTransportConstants.SPP_UUID]): [connect] — бесконечный
|
||||||
|
* цикл подключения с реконнектом через [BtTransportConstants.RETRY_DELAY_MS]
|
||||||
|
* (цикл живёт внутри транспорта), строки-сообщения (JSON + перевод строки —
|
||||||
|
* разделитель) читаются на Dispatchers.IO, запись — под синхронизацией на
|
||||||
|
* сокете. Адрес телефона берётся из [GlassesConfig.btRemoteMac].
|
||||||
|
* Включается флагом [GlassesConfig.USE_BLUETOOTH] (дефолт — WiFi).
|
||||||
|
*/
|
||||||
|
class BtGlassesTransport(
|
||||||
|
private val context: Context,
|
||||||
|
) : GlassesTransport {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||||
|
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||||
|
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||||
|
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||||
|
*/
|
||||||
|
fun runtimePermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||||
|
runtimePermissions().all {
|
||||||
|
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name = "bluetooth"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
|
||||||
|
private val outLock = Any()
|
||||||
|
private var out: OutputStream? = null
|
||||||
|
|
||||||
|
/** Бесконечный цикл подключения+реконнекта: в корутине вызывающего, до [close]. */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override suspend fun connect(
|
||||||
|
onMessage: suspend (String) -> Unit,
|
||||||
|
onConnected: suspend () -> Unit,
|
||||||
|
onDisconnected: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
while (!closed) {
|
||||||
|
var connected = false
|
||||||
|
if (socket == null) {
|
||||||
|
if (!ensureSocket()) {
|
||||||
|
log("bt", "SPP не создан (радио/пермишен/адрес?) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log("bt", "SPP-клиент готов: ${GlassesConfig.btRemoteMac()}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val sock = socket!!
|
||||||
|
withContext(Dispatchers.IO) { sock.connect() }
|
||||||
|
connected = true
|
||||||
|
out = sock.outputStream
|
||||||
|
log("bt", "SPP-канал открыт: ${GlassesConfig.btRemoteMac()}")
|
||||||
|
onConnected()
|
||||||
|
// readLines: EOF (партнёр закрыл) или исключение (обрыв/close)
|
||||||
|
readLines(sock, onMessage)
|
||||||
|
if (!closed) {
|
||||||
|
log("bt", "SPP-соединение оборвано")
|
||||||
|
onDisconnected()
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "SPP-ошибка (${e::class.simpleName}: ${e.message}) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
if (!closed && connected) runCatching { onDisconnected() }
|
||||||
|
}
|
||||||
|
runCatching { out?.close() }
|
||||||
|
out = null
|
||||||
|
runCatching { socket?.close() }
|
||||||
|
socket = null
|
||||||
|
if (closed) break
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFCOMM/SPP-сокет к [GlassesConfig.btRemoteMac]; без радио/пермишена/адреса — false. */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun ensureSocket(): Boolean {
|
||||||
|
val mac = GlassesConfig.btRemoteMac()
|
||||||
|
if (mac.isNullOrBlank()) {
|
||||||
|
log("bt", "адрес телефона не задан (GlassesConfig.BT_REMOTE_MAC) — SPP недоступен")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
|
||||||
|
log("bt", "BT-радио не найдено — SPP недоступен")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!adapter.isEnabled) {
|
||||||
|
log("bt", "Bluetooth выключен — включите в настройках Android")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!hasBtPermissions(context)) {
|
||||||
|
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val dev = try {
|
||||||
|
adapter.getRemoteDevice(mac)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "устройство не найдено: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (dev == null) return false
|
||||||
|
val s = try {
|
||||||
|
dev.createRfcommSocketToServiceRecord(UUID.fromString(BtTransportConstants.SPP_UUID))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "не удалось создать SPP-сокет: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (s == null) return false
|
||||||
|
socket = s
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
|
||||||
|
private suspend fun readLines(s: BluetoothSocket, onMessage: suspend (String) -> Unit) {
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.inputStream))
|
||||||
|
while (!closed) {
|
||||||
|
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
|
||||||
|
if (line.isNotBlank()) onMessage(line)
|
||||||
|
}
|
||||||
|
runCatching { reader.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-строка в сокет: UTF-8 + разделитель, под синхронизацией. Без соединения — no-op. */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override suspend fun send(json: String) {
|
||||||
|
val o = out ?: return
|
||||||
|
runCatching {
|
||||||
|
synchronized(outLock) {
|
||||||
|
o.write(json.toByteArray(Charsets.UTF_8))
|
||||||
|
o.write(BtTransportConstants.NEWLINE.code)
|
||||||
|
o.flush()
|
||||||
|
}
|
||||||
|
}.onFailure { log("bt", "запись не удалась: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
try {
|
||||||
|
socket?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,27 @@ import com.rayneo.arsdk.android.MercurySDK
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||||
|
data class ChatEntry(val role: String, val text: String)
|
||||||
|
|
||||||
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
||||||
object GlassesConfig {
|
object GlassesConfig {
|
||||||
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||||
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
||||||
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор транспорта (TASK-transport.md п.4): `false` (дефолт) — WiFi
|
||||||
|
* ([WifiGlassesTransport], поведение без изменений); `true` — Bluetooth SPP
|
||||||
|
* ([BtGlassesTransport], manual-включение).
|
||||||
|
*/
|
||||||
|
const val USE_BLUETOOTH = true
|
||||||
|
|
||||||
|
/** MAC-адрес телефона-партнёра для SPP (нужен только при USE_BLUETOOTH=true). */
|
||||||
|
val BT_REMOTE_MAC: String? = "08:84:FB:D5:BD:3E"
|
||||||
|
|
||||||
|
/** Адрес SPP-партнёра (null → SPP недоступен, транспорт ляжет с ретраями). */
|
||||||
|
fun btRemoteMac(): String? = BT_REMOTE_MAC
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +52,9 @@ class GlassesApp : Application() {
|
|||||||
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
|
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
|
||||||
val scale = MutableStateFlow(1f)
|
val scale = MutableStateFlow(1f)
|
||||||
|
|
||||||
|
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
|
||||||
|
val vertical = MutableStateFlow(1f)
|
||||||
|
|
||||||
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
|
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
|
||||||
val listening = MutableStateFlow(false)
|
val listening = MutableStateFlow(false)
|
||||||
|
|
||||||
@@ -45,6 +64,27 @@ class GlassesApp : Application() {
|
|||||||
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||||
val assistantText = MutableStateFlow("")
|
val assistantText = MutableStateFlow("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
|
||||||
|
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
|
||||||
|
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
|
||||||
|
*/
|
||||||
|
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||||
|
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||||
|
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
|
||||||
|
*/
|
||||||
|
val thinking = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
|
||||||
|
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
|
||||||
|
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
|
||||||
|
*/
|
||||||
|
val answerScrollDelta = MutableStateFlow(0f)
|
||||||
|
|
||||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||||
var mainActivity: MainActivity? = null
|
var mainActivity: MainActivity? = null
|
||||||
|
|
||||||
@@ -55,10 +95,18 @@ class GlassesApp : Application() {
|
|||||||
prefs.edit().putFloat("scale", v).apply()
|
prefs.edit().putFloat("scale", v).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Применить и сохранить вертикаль (0..1). */
|
||||||
|
fun applyVertical(value: Float) {
|
||||||
|
val v = value.coerceIn(0f, 1f)
|
||||||
|
vertical.value = v
|
||||||
|
prefs.edit().putFloat("vertical", v).apply()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
|
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
|
||||||
|
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
|
||||||
MercurySDK.init(this)
|
MercurySDK.init(this)
|
||||||
movieController = MovieController(applicationContext)
|
movieController = MovieController(applicationContext)
|
||||||
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
|
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
import pw.binom.viewmate.core.net.GlassesWsClient
|
import pw.binom.viewmate.core.net.GlassesTransport
|
||||||
|
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||||
@@ -31,19 +36,22 @@ import pw.binom.viewmate.core.protocol.PlayVideo
|
|||||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||||
import pw.binom.viewmate.core.protocol.SetMode
|
import pw.binom.viewmate.core.protocol.SetMode
|
||||||
import pw.binom.viewmate.core.protocol.SetScale
|
import pw.binom.viewmate.core.protocol.SetScale
|
||||||
|
import pw.binom.viewmate.core.protocol.SetVertical
|
||||||
import pw.binom.viewmate.core.protocol.Shutdown
|
import pw.binom.viewmate.core.protocol.Shutdown
|
||||||
import pw.binom.viewmate.core.protocol.ShowText
|
import pw.binom.viewmate.core.protocol.ShowText
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
import pw.binom.viewmate.core.protocol.SttDone
|
import pw.binom.viewmate.core.protocol.SttDone
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||||
import pw.binom.viewmate.core.protocol.Welcome
|
import pw.binom.viewmate.core.protocol.Welcome
|
||||||
|
import pw.binom.viewmate.core.protocol.protocolJson
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обвязка над [GlassesWsClient] для UI очков:
|
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
|
||||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
|
||||||
* Hello при подключении, периодический статус/позиция,
|
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
|
||||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
|
||||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
|
||||||
|
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
|
||||||
*/
|
*/
|
||||||
class HostConnection(
|
class HostConnection(
|
||||||
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
||||||
@@ -56,11 +64,14 @@ class HostConnection(
|
|||||||
private val discovery = MdnsClient(context)
|
private val discovery = MdnsClient(context)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
|
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||||||
@Volatile
|
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||||||
private var client: GlassesWsClient? = null
|
|
||||||
|
|
||||||
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
|
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||||||
|
@Volatile
|
||||||
|
private var transport: GlassesTransport? = null
|
||||||
|
|
||||||
|
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var lastUrl: String? = null
|
private var lastUrl: String? = null
|
||||||
|
|
||||||
@@ -81,10 +92,10 @@ class HostConnection(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
client?.send(buildStatus())
|
sendToHost(buildStatus())
|
||||||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||||||
client?.send(
|
sendToHost(
|
||||||
PlaybackPosition(
|
PlaybackPosition(
|
||||||
positionMs = snapshot.positionMs,
|
positionMs = snapshot.positionMs,
|
||||||
playing = snapshot.playing,
|
playing = snapshot.playing,
|
||||||
@@ -96,36 +107,80 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
|
||||||
|
private suspend fun connectLoop() {
|
||||||
|
if (useBt) connectLoopBt() else connectLoopWifi()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
|
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||||||
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||||||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||||||
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
|
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||||||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||||||
* повторный mDNS-цикл.
|
* повторный mDNS-цикл.
|
||||||
*/
|
*/
|
||||||
private suspend fun connectLoop() {
|
private suspend fun connectLoopWifi() {
|
||||||
val url = lastUrl ?: discoverPhoneUrl()
|
val url = lastUrl ?: discoverPhoneUrl()
|
||||||
if (url.isEmpty()) {
|
if (url.isEmpty()) {
|
||||||
log("app", "телефон не найден — повтор поиска через 3с")
|
log("app", "телефон не найден — повтор поиска через 3с")
|
||||||
delay(3_000)
|
delay(3_000)
|
||||||
connectLoop()
|
connectLoopWifi()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastUrl = url
|
lastUrl = url
|
||||||
log("app", "подключаюсь к $url")
|
log("app", "подключаюсь к $url (WiFi)")
|
||||||
|
|
||||||
val c = GlassesWsClient(url)
|
val t = WifiGlassesTransport(url)
|
||||||
client = c
|
transport = t
|
||||||
c.connect(
|
t.connect(
|
||||||
onHostMessage = { msg -> handleHostMessage(msg) },
|
onMessage = { text -> handleHostMessageText(text) },
|
||||||
onConnected = {
|
onConnected = {
|
||||||
_connected.value = true
|
_connected.value = true
|
||||||
_status.value = "подключено: $url"
|
_status.value = "подключено: ${t.name} $url"
|
||||||
addMessage("соединение установлено")
|
addMessage("соединение установлено")
|
||||||
discovery.stop()
|
discovery.stop()
|
||||||
c.send(Hello(appVersion = APP_VERSION))
|
sendToHost(Hello(appVersion = APP_VERSION))
|
||||||
|
if (reconnectResume.consume()) {
|
||||||
|
withContext(Dispatchers.Main) { movieController.resume() }
|
||||||
|
addMessage("связь восстановлена — продолжаю")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDisconnected = {
|
||||||
|
_connected.value = false
|
||||||
|
_status.value = "нет соединения (реконнект через 3с)"
|
||||||
|
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
|
||||||
|
reconnectResume.onConnectionLost(wasPlaying)
|
||||||
|
if (wasPlaying) addMessage("связь потеряна — пауза")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено
|
||||||
|
* вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect].
|
||||||
|
*/
|
||||||
|
private suspend fun connectLoopBt() {
|
||||||
|
val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "")
|
||||||
|
if (mac.isEmpty()) {
|
||||||
|
log("app", "BT: MAC-адрес телефона не задан — SPP недоступен")
|
||||||
|
delay(3_000)
|
||||||
|
connectLoopBt()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastUrl = mac
|
||||||
|
log("app", "подключаюсь к $mac (Bluetooth SPP)")
|
||||||
|
|
||||||
|
val t = BtGlassesTransport(appContext)
|
||||||
|
transport = t
|
||||||
|
t.connect(
|
||||||
|
onMessage = { text -> handleHostMessageText(text) },
|
||||||
|
onConnected = {
|
||||||
|
_connected.value = true
|
||||||
|
_status.value = "подключено: bluetooth $mac"
|
||||||
|
addMessage("соединение установлено")
|
||||||
|
sendToHost(Hello(appVersion = APP_VERSION))
|
||||||
if (reconnectResume.consume()) {
|
if (reconnectResume.consume()) {
|
||||||
withContext(Dispatchers.Main) { movieController.resume() }
|
withContext(Dispatchers.Main) { movieController.resume() }
|
||||||
addMessage("связь восстановлена — продолжаю")
|
addMessage("связь восстановлена — продолжаю")
|
||||||
@@ -145,7 +200,7 @@ class HostConnection(
|
|||||||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||||||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||||||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
|
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||||||
*/
|
*/
|
||||||
private suspend fun discoverPhoneUrl(): String {
|
private suspend fun discoverPhoneUrl(): String {
|
||||||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||||||
@@ -171,18 +226,29 @@ class HostConnection(
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */
|
||||||
|
private suspend fun handleHostMessageText(text: String) {
|
||||||
|
val msg = runCatching {
|
||||||
|
protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
}.onFailure {
|
||||||
|
log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})")
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
handleHostMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
||||||
suspend fun sendToHost(msg: GlassesToHost) {
|
suspend fun sendToHost(msg: GlassesToHost) {
|
||||||
client?.send(msg)
|
val t = transport ?: return
|
||||||
|
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||||||
fun sendPlaybackPositionNow() {
|
fun sendPlaybackPositionNow() {
|
||||||
val c = client ?: return
|
if (transport == null) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val (positionMs, playing, itemId, audioIndex) =
|
val (positionMs, playing, itemId, audioIndex) =
|
||||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||||
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +310,11 @@ class HostConnection(
|
|||||||
log("app", "scale → ${msg.scale}")
|
log("app", "scale → ${msg.scale}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SetVertical -> {
|
||||||
|
GlassesApp.instance.applyVertical(msg.vertical)
|
||||||
|
log("app", "vertical → ${msg.vertical}")
|
||||||
|
}
|
||||||
|
|
||||||
is PlayVideo -> {
|
is PlayVideo -> {
|
||||||
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
||||||
reconnectResume.reset()
|
reconnectResume.reset()
|
||||||
@@ -270,11 +341,20 @@ class HostConnection(
|
|||||||
|
|
||||||
is ShowText -> {
|
is ShowText -> {
|
||||||
addMessage("ShowText: ${msg.text}")
|
addMessage("ShowText: ${msg.text}")
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
|
GlassesApp.instance.answerScrollDelta.value = 0f
|
||||||
GlassesApp.instance.assistantText.value = msg.text
|
GlassesApp.instance.assistantText.value = msg.text
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
||||||
|
|
||||||
|
is ChatHistoryMsg -> {
|
||||||
|
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
|
||||||
|
GlassesApp.instance.chatHistory.value = msg.messages
|
||||||
|
.filter { it.role == "user" || it.role == "assistant" }
|
||||||
|
.map { ChatEntry(it.role, it.content) }
|
||||||
|
}
|
||||||
|
|
||||||
is SttPhrase -> {
|
is SttPhrase -> {
|
||||||
GlassesApp.instance.sttText.value = msg.full
|
GlassesApp.instance.sttText.value = msg.full
|
||||||
log("stt", "фраза: ${msg.phrase}")
|
log("stt", "фраза: ${msg.phrase}")
|
||||||
@@ -286,8 +366,31 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SttCancel -> {
|
is SttCancel -> {
|
||||||
log("stt", "отменено")
|
when (msg.reason) {
|
||||||
|
SttCancel.REASON_CANCEL -> {
|
||||||
|
// Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить
|
||||||
|
// listening/thinking, НО остаться в режиме (показать последний ответ),
|
||||||
|
// микрофон заново НЕ открывать, из режима НЕ выходить.
|
||||||
|
log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме")
|
||||||
GlassesApp.instance.listening.value = false
|
GlassesApp.instance.listening.value = false
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
|
}
|
||||||
|
SttCancel.REASON_TIMEOUT -> {
|
||||||
|
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
|
||||||
|
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
|
||||||
|
log("stt", "тишина 30с — выход из режима общения")
|
||||||
|
GlassesApp.instance.mainActivity?.onSttTimeout()
|
||||||
|
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// reason="user" — пустой клик без голоса: не выходим из режима,
|
||||||
|
// возвращаемся в «слушаю» (микрофон снова открыт).
|
||||||
|
log("stt", "отменено — возврат в «слушаю»")
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
|
GlassesApp.instance.mainActivity?.resumeSttListening()
|
||||||
|
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Shutdown -> {
|
is Shutdown -> {
|
||||||
@@ -306,7 +409,7 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
client?.close()
|
transport?.close()
|
||||||
discovery.stop()
|
discovery.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -18,7 +19,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -36,7 +36,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -51,10 +50,14 @@ import pw.binom.viewmate.core.MediaCommand
|
|||||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||||
import pw.binom.viewmate.core.protocol.SttAudio
|
import pw.binom.viewmate.core.protocol.SttAudio
|
||||||
import pw.binom.viewmate.core.protocol.StopStt
|
import pw.binom.viewmate.core.protocol.StopStt
|
||||||
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||||
|
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
||||||
import pw.binom.viewmate.glasses.gestures.actionName
|
import pw.binom.viewmate.glasses.gestures.actionName
|
||||||
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
|
import pw.binom.viewmate.glasses.gestures.assistantMode
|
||||||
|
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
||||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
import pw.binom.viewmate.glasses.stt.SttMicStream
|
||||||
|
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||||
import pw.binom.viewmate.glasses.ui.Binocular
|
import pw.binom.viewmate.glasses.ui.Binocular
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
||||||
@@ -62,6 +65,7 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
|
|||||||
import pw.binom.viewmate.glasses.ui.Side
|
import pw.binom.viewmate.glasses.ui.Side
|
||||||
import pw.binom.viewmate.glasses.ui.px
|
import pw.binom.viewmate.glasses.ui.px
|
||||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||||
|
import pw.binom.viewmate.glasses.ui.panelOffsetX
|
||||||
|
|
||||||
class MainActivity : BaseEventActivity() {
|
class MainActivity : BaseEventActivity() {
|
||||||
private var resumeAfterScreenOn = false
|
private var resumeAfterScreenOn = false
|
||||||
@@ -79,9 +83,9 @@ class MainActivity : BaseEventActivity() {
|
|||||||
private val debugReceiver = object : BroadcastReceiver() {
|
private val debugReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
when (intent?.getStringExtra("type")) {
|
when (intent?.getStringExtra("type")) {
|
||||||
"triple_click" -> handleTripleClick()
|
"triple_click" -> onTempleAction(TempleAction.TripleClick())
|
||||||
"click" -> handleClick()
|
"click" -> onTempleAction(TempleAction.Click())
|
||||||
"double_click" -> handleDoubleClick()
|
"double_click" -> onTempleAction(TempleAction.DoubleClick())
|
||||||
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +120,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
GlassesApp.instance.mainActivity = this
|
GlassesApp.instance.mainActivity = this
|
||||||
|
requestBtPermissions()
|
||||||
collectTempleActions()
|
collectTempleActions()
|
||||||
disableTrackpadSounds()
|
disableTrackpadSounds()
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
@@ -135,6 +140,9 @@ class MainActivity : BaseEventActivity() {
|
|||||||
} else {
|
} else {
|
||||||
ConnectionScreen()
|
ConnectionScreen()
|
||||||
}
|
}
|
||||||
|
// Оверлей режима общения: поверх любого экрана (затемняет кино),
|
||||||
|
// виден в «слушаю»/«думаю»/«ответ виден».
|
||||||
|
AssistantOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,78 +196,162 @@ class MainActivity : BaseEventActivity() {
|
|||||||
}, null)
|
}, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun requestBtPermissions() {
|
||||||
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
|
if (!GlassesConfig.USE_BLUETOOTH) return
|
||||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
val granted = BtGlassesTransport.runtimePermissions().all {
|
||||||
* завершить распознавание, DoubleClick — отмена.
|
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||||
*/
|
}
|
||||||
|
if (granted) return
|
||||||
|
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
|
||||||
|
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
|
||||||
private fun collectTempleActions() {
|
private fun collectTempleActions() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
templeActionViewModel.state.collect { action ->
|
templeActionViewModel.state.collect { action ->
|
||||||
when (action) {
|
onTempleAction(action)
|
||||||
is TempleAction.TripleClick -> handleTripleClick()
|
|
||||||
is TempleAction.Click -> handleClick()
|
|
||||||
is TempleAction.DoubleClick -> handleDoubleClick()
|
|
||||||
is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
|
|
||||||
val cmd = templeActionToCommand(action)
|
|
||||||
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
|
|
||||||
if (cmd != null) {
|
|
||||||
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
|
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> log("gesture", "жест (резерв): ${actionName(action)}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
|
/**
|
||||||
private fun handleTripleClick() {
|
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
||||||
if (GlassesApp.instance.listening.value) {
|
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
|
||||||
|
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
|
||||||
|
*/
|
||||||
|
private fun onTempleAction(action: TempleAction) {
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
val state = AssistantState(
|
||||||
|
listening = app.listening.value,
|
||||||
|
thinking = app.thinking.value,
|
||||||
|
sttText = app.sttText.value,
|
||||||
|
assistantText = app.assistantText.value,
|
||||||
|
)
|
||||||
|
when (val decision = gestureDecision(action, state)) {
|
||||||
|
GestureDecision.Ignore ->
|
||||||
|
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
|
||||||
|
GestureDecision.CallAssistant -> startAssistantMode()
|
||||||
|
GestureDecision.TogglePlayback -> togglePlayback()
|
||||||
|
GestureDecision.SendPhrase -> sendPhrase()
|
||||||
|
GestureDecision.NewPhrase -> startNewPhrase()
|
||||||
|
GestureDecision.CancelDictation -> cancelDictation()
|
||||||
|
GestureDecision.ExitMode -> exitAssistantMode()
|
||||||
|
is GestureDecision.Seek -> seekBy(decision)
|
||||||
|
is GestureDecision.ScrollAnswer -> {
|
||||||
|
val acc = app.answerScrollDelta.value + decision.delta
|
||||||
|
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
|
||||||
|
app.answerScrollDelta.value = acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
|
||||||
|
private fun startAssistantMode() {
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
if (app.listening.value) {
|
||||||
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log("stt", "ассистент: слушаю")
|
log("stt", "ассистент: слушаю")
|
||||||
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
|
if (app.movieController.pauseIfPlaying()) {
|
||||||
log("stt", "кино на паузе (TripleClick)")
|
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||||
}
|
}
|
||||||
GlassesApp.instance.listening.value = true
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
GlassesApp.instance.sttText.value = ""
|
app.listening.value = true
|
||||||
GlassesApp.instance.assistantText.value = ""
|
app.thinking.value = false
|
||||||
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
|
app.sttText.value = ""
|
||||||
|
app.assistantText.value = ""
|
||||||
|
app.answerScrollDelta.value = 0f
|
||||||
|
sttMic.start(filesDir) { app.listening.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
|
/** Click (кино): плей/пауза — как сегодня. */
|
||||||
private fun handleClick() {
|
private fun togglePlayback() {
|
||||||
if (GlassesApp.instance.listening.value) {
|
|
||||||
log("gesture", "Click → завершить распознавание")
|
|
||||||
sttMic.stop()
|
|
||||||
lifecycleScope.launch {
|
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
||||||
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
|
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||||
private fun handleDoubleClick() {
|
private fun sendPhrase() {
|
||||||
if (GlassesApp.instance.listening.value) {
|
log("gesture", "Click → отправить фразу")
|
||||||
|
sttMic.stop()
|
||||||
|
GlassesApp.instance.listening.value = false
|
||||||
|
GlassesApp.instance.thinking.value = true
|
||||||
|
lifecycleScope.launch {
|
||||||
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||||
|
private fun startNewPhrase() {
|
||||||
|
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
app.assistantText.value = ""
|
||||||
|
app.sttText.value = ""
|
||||||
|
app.thinking.value = false
|
||||||
|
app.answerScrollDelta.value = 0f
|
||||||
|
app.listening.value = true
|
||||||
|
sttMic.start(filesDir) { app.listening.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
|
||||||
|
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
|
||||||
|
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
|
||||||
|
*/
|
||||||
|
fun resumeSttListening() {
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
app.thinking.value = false
|
||||||
|
app.listening.value = true
|
||||||
|
sttMic.start(filesDir) { app.listening.value }
|
||||||
|
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
||||||
|
private fun cancelDictation() {
|
||||||
log("gesture", "DoubleClick → отмена распознавания")
|
log("gesture", "DoubleClick → отмена распознавания")
|
||||||
sttMic.stop()
|
sttMic.stop()
|
||||||
|
GlassesApp.instance.listening.value = false
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log("gesture", "жест (резерв): DoubleClick")
|
|
||||||
|
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
||||||
|
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
|
||||||
|
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
|
||||||
|
*/
|
||||||
|
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||||
|
|
||||||
|
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
||||||
|
private fun finishAssistantMode(reason: String) {
|
||||||
|
log("gesture", reason)
|
||||||
|
sttMic.stop()
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
app.listening.value = false
|
||||||
|
app.thinking.value = false
|
||||||
|
app.sttText.value = ""
|
||||||
|
app.assistantText.value = ""
|
||||||
|
app.answerScrollDelta.value = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
|
||||||
|
private fun seekBy(seek: GestureDecision.Seek) {
|
||||||
|
log("gesture", "жест: свайп → ${seek.command}")
|
||||||
|
GlassesApp.instance.movieController.applyMediaCommand(
|
||||||
|
MediaCommandMsg(seek.command, seek.seekMs)
|
||||||
|
) {
|
||||||
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,11 +379,18 @@ class MainActivity : BaseEventActivity() {
|
|||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
log("ui", "onPause")
|
log("ui", "onPause")
|
||||||
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
val app = GlassesApp.instance
|
||||||
|
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
|
||||||
|
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
|
||||||
|
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
|
||||||
|
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
|
||||||
|
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
|
||||||
|
}
|
||||||
|
val was = app.movieController.pauseIfPlaying()
|
||||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||||
logEvent("onPause: pause wasPlaying=$was")
|
logEvent("onPause: pause wasPlaying=$was")
|
||||||
GlassesApp.instance.movieController.ensureWifiLock()
|
app.movieController.ensureWifiLock()
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
app.hostConnection.sendPlaybackPositionNow()
|
||||||
if (was) log("ui", "экран погас — пауза")
|
if (was) log("ui", "экран погас — пауза")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,11 +472,17 @@ fun ConnectionScreen() {
|
|||||||
val containerW = (screenWidthPx * scale).toInt()
|
val containerW = (screenWidthPx * scale).toInt()
|
||||||
val containerH = (screenHeightPx * scale).toInt()
|
val containerH = (screenHeightPx * scale).toInt()
|
||||||
|
|
||||||
|
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||||
|
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(containerW.px)
|
.width(containerW.px)
|
||||||
.height(containerH.px)
|
.height(containerH.px)
|
||||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
.offset(
|
||||||
|
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||||
|
// вертикаль (vertical) — только для видео; здесь контент всегда у верха
|
||||||
|
)
|
||||||
.background(Color.Black),
|
.background(Color.Black),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -394,46 +499,16 @@ fun ConnectionScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||||
|
}
|
||||||
|
|
||||||
StatusPanelCompose(
|
StatusPanelCompose(
|
||||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
modifier = Modifier
|
||||||
scale = scale,
|
.align(Alignment.BottomCenter)
|
||||||
|
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
|
||||||
)
|
)
|
||||||
|
|
||||||
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
|
|
||||||
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
|
|
||||||
// (assistantText) — ответ читается до новой сессии.
|
|
||||||
val listening by GlassesApp.instance.listening.collectAsState()
|
|
||||||
val sttText by GlassesApp.instance.sttText.collectAsState()
|
|
||||||
val assistantText by GlassesApp.instance.assistantText.collectAsState()
|
|
||||||
if (listening || assistantText.isNotBlank()) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.72f)),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.align(Alignment.Center).padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (sttText.isBlank()) "слушаю..." else sttText,
|
|
||||||
color = Color.White,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
if (assistantText.isNotBlank()) {
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
assistantText,
|
|
||||||
color = Color(0xFFB0BEC5),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package pw.binom.viewmate.glasses.gestures
|
||||||
|
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction
|
||||||
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||||
|
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
|
||||||
|
*
|
||||||
|
* @NONE — кино: ассистент не вызван.
|
||||||
|
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
|
||||||
|
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
|
||||||
|
* @ANSWER — ответ виден: listening=false и assistantText непуст.
|
||||||
|
*/
|
||||||
|
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
|
||||||
|
|
||||||
|
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
|
||||||
|
data class AssistantState(
|
||||||
|
/** Микрофон открыт (TripleClick → слушаю). */
|
||||||
|
val listening: Boolean = false,
|
||||||
|
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
|
||||||
|
val thinking: Boolean = false,
|
||||||
|
/** Накопленные фразы (SttPhrase, full). */
|
||||||
|
val sttText: String = "",
|
||||||
|
/** Ответ ассистента (ShowText). */
|
||||||
|
val assistantText: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
|
||||||
|
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
|
||||||
|
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
|
||||||
|
*/
|
||||||
|
fun assistantMode(state: AssistantState): AssistantMode = when {
|
||||||
|
state.listening -> AssistantMode.LISTENING
|
||||||
|
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
||||||
|
state.thinking -> AssistantMode.THINKING
|
||||||
|
else -> AssistantMode.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
|
||||||
|
* Матрица (docs/gestures-and-modes.md):
|
||||||
|
*
|
||||||
|
* | Режим | Клик | Двойной | Тройной | Свайпы |
|
||||||
|
* |------------------|--------------------|-----------|---------|----------------------|
|
||||||
|
* | Кино | плей/пауза | игнор | вызов | ±30с |
|
||||||
|
* | Слушаю | отправить фразу | отмена | игнор | игнор |
|
||||||
|
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
|
||||||
|
*/
|
||||||
|
sealed interface GestureDecision {
|
||||||
|
/** Жест в данном режиме не назначен (напр. DoubleClick в кино, свайпы в «слушаю»). */
|
||||||
|
data object Ignore : GestureDecision
|
||||||
|
|
||||||
|
/** TripleClick (кино): пауза фильма + открыть микрофон. */
|
||||||
|
data object CallAssistant : GestureDecision
|
||||||
|
|
||||||
|
/** Клик в кино: плей/пауза (как сегодня, MediaCommand.TOGGLE). */
|
||||||
|
data object TogglePlayback : GestureDecision
|
||||||
|
|
||||||
|
/** Клик в «слушаю»: закрыть микрофон, отправить стоп-STT (фраза на телефон). */
|
||||||
|
data object SendPhrase : GestureDecision
|
||||||
|
|
||||||
|
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
|
||||||
|
data object NewPhrase : GestureDecision
|
||||||
|
|
||||||
|
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */
|
||||||
|
data object CancelDictation : GestureDecision
|
||||||
|
|
||||||
|
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||||
|
data object ExitMode : GestureDecision
|
||||||
|
|
||||||
|
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
|
||||||
|
data class Seek(val command: MediaCommand, val seekMs: Long) : GestureDecision
|
||||||
|
|
||||||
|
/** Свайпы при «ответе виден»: дельта скролла ответа, px. Положительная — к концу текста. */
|
||||||
|
data class ScrollAnswer(val delta: Float) : GestureDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
|
||||||
|
*
|
||||||
|
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
|
||||||
|
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
|
||||||
|
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
|
||||||
|
* меняем знак здесь, в одной точке).
|
||||||
|
*/
|
||||||
|
fun gestureDecision(
|
||||||
|
action: TempleAction,
|
||||||
|
state: AssistantState,
|
||||||
|
seekStepMs: Long = 30_000,
|
||||||
|
pageDeltaPx: Float = 200f,
|
||||||
|
): GestureDecision = when (val mode = assistantMode(state)) {
|
||||||
|
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
|
||||||
|
AssistantMode.NONE -> when (action) {
|
||||||
|
is TempleAction.Click -> GestureDecision.TogglePlayback
|
||||||
|
is TempleAction.TripleClick -> GestureDecision.CallAssistant
|
||||||
|
else ->
|
||||||
|
templeActionToCommand(action, seekStepMs)?.let { GestureDecision.Seek(it.command, it.seekMs) }
|
||||||
|
?: GestureDecision.Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
AssistantMode.LISTENING -> when (action) {
|
||||||
|
is TempleAction.Click -> GestureDecision.SendPhrase
|
||||||
|
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
|
||||||
|
else -> GestureDecision.Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
AssistantMode.THINKING -> when (action) {
|
||||||
|
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||||
|
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||||
|
else -> GestureDecision.Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
AssistantMode.ANSWER -> when (action) {
|
||||||
|
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||||
|
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||||
|
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
|
||||||
|
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
|
||||||
|
is TempleAction.SlideContinuous ->
|
||||||
|
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
|
||||||
|
// два строка выше.
|
||||||
|
GestureDecision.ScrollAnswer(action.delta)
|
||||||
|
else -> GestureDecision.Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
|
||||||
|
fun overlayVisible(state: AssistantState): Boolean =
|
||||||
|
state.listening || state.thinking || state.assistantText.isNotBlank()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заголовок оверлея (над ответом). null — только ответ.
|
||||||
|
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
|
||||||
|
* listening ещё true — микрофон уже закрыт);
|
||||||
|
* - «слушаю...»/фраза — микрофон открыт;
|
||||||
|
* - при «ответе виден» — последняя фраза как контекст (или null).
|
||||||
|
*/
|
||||||
|
fun overlayHeader(state: AssistantState): String? = when {
|
||||||
|
state.thinking && state.assistantText.isBlank() -> "думаю..."
|
||||||
|
state.listening -> state.sttText.ifBlank { "слушаю..." }
|
||||||
|
else -> state.sttText.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
|
||||||
|
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
|
||||||
|
* Чистая функция — unit-тесты без Android.
|
||||||
|
*/
|
||||||
|
fun scrollOffset(currentOffset: Int, delta: Float, maxScroll: Int): Int =
|
||||||
|
(currentOffset + delta).toInt().coerceIn(0, maxScroll)
|
||||||
@@ -3,8 +3,6 @@ package pw.binom.viewmate.glasses.stt
|
|||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
import android.media.AudioRecord
|
import android.media.AudioRecord
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -12,14 +10,22 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.glasses.log
|
import pw.binom.viewmate.glasses.log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
|
* Поток микрофона очков (TASK-mic): 16 кГц s16 mono, полный поток БЕЗ VAD-нарезки.
|
||||||
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
|
|
||||||
* filesDir/samples (отладка, см. TASK.md этап 2).
|
|
||||||
*
|
*
|
||||||
* Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening):
|
* История: WebRTC VAD резал речь на огрызки — микрофон ARGT78 пишет рвано
|
||||||
* как только очки перестали слушать, поток обрывается.
|
* (пачки ~50 мс звука / ~50 мс тишины), VAD принимал внутренние провалы за паузы
|
||||||
|
* и ловил лишь ~15% речи. Локальный тест whisper на сыром дампе («1,2,3,4,5
|
||||||
|
* 6,7,8,9,10») распознал полный файл идеально — whisper сам справляется с
|
||||||
|
* рваностью и тихим уровнем (пик ~1500). Поэтому: весь PCM уходит на телефон
|
||||||
|
* чанками по 100 мс, усиление ×3 (без клиппа), телефон копит полный буфер
|
||||||
|
* сессии и шлёт его в whisper по клику.
|
||||||
*/
|
*/
|
||||||
class SttMicStream(
|
class SttMicStream(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
@@ -27,42 +33,64 @@ class SttMicStream(
|
|||||||
) {
|
) {
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
|
/** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */
|
||||||
|
private var dump: WavWriter? = null
|
||||||
|
|
||||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
|
val minBuffer = AudioRecord.getMinBufferSize(
|
||||||
|
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
)
|
||||||
|
val bufferBytes = maxOf(minBuffer, MIC_CHUNK_FRAMES * FRAME_SIZE * 2)
|
||||||
val recorder = runCatching {
|
val recorder = runCatching {
|
||||||
AudioRecord(
|
AudioRecord(
|
||||||
MediaRecorder.AudioSource.MIC,
|
MediaRecorder.AudioSource.MIC,
|
||||||
16_000,
|
MIC_SAMPLE_RATE,
|
||||||
AudioFormat.CHANNEL_IN_MONO,
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
6_400,
|
bufferBytes,
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
|
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
recorder?.release()
|
recorder?.release()
|
||||||
log("stt", "микрофон недоступен — отладка: PCM из файла")
|
log("stt", "микрофон недоступен — отладка: PCM из файла")
|
||||||
|
dump = null
|
||||||
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
|
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dump = openLiveMicDump(filesDir)
|
||||||
|
val dumpWriter = dump
|
||||||
job = scope.launch(Dispatchers.IO) {
|
job = scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
recorder.startRecording()
|
recorder.startRecording()
|
||||||
log("stt", "микрофон запущен (16 кГц, s16, mono)")
|
log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)")
|
||||||
val buf = ByteArray(3_200)
|
val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс
|
||||||
|
val chunk = ArrayList<ShortArray>(MIC_CHUNK_FRAMES)
|
||||||
while (isActive && shouldRun()) {
|
while (isActive && shouldRun()) {
|
||||||
val n = recorder.read(buf, 0, buf.size)
|
val n = recorder.read(frame, 0, FRAME_SIZE) // кадры
|
||||||
if (n > 0) {
|
if (n <= 0) continue
|
||||||
val chunk = if (n == buf.size) buf else buf.copyOf(n)
|
val data = if (n == FRAME_SIZE) frame else frame.copyOf(n)
|
||||||
onChunk(chunk)
|
// СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки.
|
||||||
|
if (dumpWriter != null) {
|
||||||
|
runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) }
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
|
}
|
||||||
|
chunk.add(gainShort(data, MIC_GAIN))
|
||||||
|
if (chunk.size >= MIC_CHUNK_FRAMES) {
|
||||||
|
onChunk(shortsToPcm16(chunk))
|
||||||
|
chunk.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (chunk.isNotEmpty()) onChunk(shortsToPcm16(chunk)) // остаток < 100 мс
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
log("stt", "микрофон: ${t.message}")
|
log("stt", "микрофон: ${t.message}")
|
||||||
} finally {
|
} finally {
|
||||||
runCatching { recorder.stop() }
|
runCatching { recorder.stop() }
|
||||||
recorder.release()
|
recorder.release()
|
||||||
|
runCatching { dumpWriter?.close() }
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
log("stt", "микрофон остановлен")
|
log("stt", "микрофон остановлен")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +99,23 @@ class SttMicStream(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
job = null
|
||||||
|
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||||
|
* открыть stt_dump_HHmmss.wav — сырой поток микрофона допишется туда
|
||||||
|
* (эмуляторную ветку не трогаем).
|
||||||
|
*/
|
||||||
|
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||||
|
if (!File(filesDir, "stt_dump.marker").exists()) return null
|
||||||
|
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"))
|
||||||
|
val path = File(filesDir, "stt_dump_$stamp.wav")
|
||||||
|
return runCatching {
|
||||||
|
WavWriter(path).also { log("stt", "dump микрофона: $path") }
|
||||||
|
}
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +151,115 @@ class SttMicStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Фрейм чтения микрофона: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
|
||||||
|
internal const val FRAME_SIZE = 320
|
||||||
|
|
||||||
|
/** Чанк на телефон: 100 мс = 5 фреймов по 20 мс. */
|
||||||
|
internal const val MIC_CHUNK_FRAMES = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Коэффициент усиления микрофона: ×3 (без клиппа — ARGT78 пишет пик 1–9%, ×3 даёт
|
||||||
|
* 3–27%). Whisper распознал и сырой (пик 1521), и ×3, и ×10 — усиление нужно
|
||||||
|
* только чтобы телефонный whisper.cpp не терял тихие участки.
|
||||||
|
*/
|
||||||
|
internal const val MIC_GAIN = 3f
|
||||||
|
|
||||||
|
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
||||||
|
internal const val CHUNK_BYTES = 3_200
|
||||||
|
|
||||||
|
/** Микрофон очков: 16 кГц — единственная рабочая частота ARGT78 (48 кГц молчит). */
|
||||||
|
internal const val MIC_SAMPLE_RATE = 16_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цифровое усиление фрейма s16le mono с насыщением (для whisper).
|
||||||
|
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||||
|
*/
|
||||||
|
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
|
||||||
|
if (gain == 1f) return samples
|
||||||
|
val out = ShortArray(samples.size)
|
||||||
|
for (i in samples.indices) {
|
||||||
|
out[i] = (samples[i] * gain).roundToInt().coerceIn(-32_768, 32_767).toShort()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Склейка фреймов (ShortArray) в один s16le ByteArray. */
|
||||||
|
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
||||||
|
var total = 0
|
||||||
|
for (f in frames) total += f.size
|
||||||
|
val out = ByteArray(total * 2)
|
||||||
|
var i = 0
|
||||||
|
for (f in frames) {
|
||||||
|
for (s in f) {
|
||||||
|
out[i++] = (s.toInt() and 0xff).toByte()
|
||||||
|
out[i++] = ((s.toInt() shr 8) and 0xff).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Даунсэмпл 48→16 кГц (сохранён для тестов; в проде не используется — 16 кГц напрямую):
|
||||||
|
* склейка 48 кГц-фреймов, FIR (скользящее среднее 3) против алиасинга, каждый 3-й сэмпл.
|
||||||
|
*/
|
||||||
|
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
|
||||||
|
var total = 0
|
||||||
|
for (f in frames) total += f.size
|
||||||
|
val outSize = total / 3
|
||||||
|
val out = ShortArray(outSize)
|
||||||
|
var src = 0
|
||||||
|
var dst = 0
|
||||||
|
var prev2 = 0
|
||||||
|
var prev1 = 0
|
||||||
|
for (f in frames) {
|
||||||
|
for (s in f) {
|
||||||
|
if (src % 3 == 2) {
|
||||||
|
val v = ((prev2 + prev1 + s) / 3)
|
||||||
|
if (dst < outSize) out[dst++] = v.toShort()
|
||||||
|
}
|
||||||
|
prev2 = prev1
|
||||||
|
prev1 = s.toInt()
|
||||||
|
src++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listOf(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
|
||||||
|
* 16-бит моно: кадр = 2 байта. Байты = frames * 2.
|
||||||
|
* Старый баг (заделан в c940a7e): return read() (кадры) сравнивали с buf.size (байты)
|
||||||
|
* — всегда ложь → copyOf(n) резал кусок пополам (терялись 2-е 50 мс каждого 100 мс).
|
||||||
|
*/
|
||||||
|
internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
|
||||||
|
val bytes = frames * 2
|
||||||
|
return if (bytes == buf.size) buf else buf.copyOf(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цифровое усиление чанка s16le mono с насыщением.
|
||||||
|
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
|
||||||
|
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
|
||||||
|
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||||
|
*/
|
||||||
|
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray {
|
||||||
|
if (gain == 1f) return bytes
|
||||||
|
val out = ByteArray(bytes.size)
|
||||||
|
var i = 0
|
||||||
|
var j = 0
|
||||||
|
while (i + 1 < bytes.size) {
|
||||||
|
val lo = bytes[i].toInt() and 0xff
|
||||||
|
val hi = bytes[i + 1].toInt() and 0xff
|
||||||
|
i += 2
|
||||||
|
var s = lo or (hi shl 8)
|
||||||
|
if (s >= 0x8000) s -= 0x10000
|
||||||
|
val v = (s * gain).roundToInt().coerceIn(-32_768, 32_767)
|
||||||
|
out[j++] = (v and 0xff).toByte()
|
||||||
|
out[j++] = ((v shr 8) and 0xff).toByte()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
|
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
|
||||||
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
|
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
|
||||||
val out = ByteArray(samples.size * 2)
|
val out = ByteArray(samples.size * 2)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный WAV-райтер (PCM s16le): 44-байтовый RIFF-заголовок пишется один раз
|
||||||
|
* при создании файла, [write] дописывает чанки, на close в заголовок дописываются
|
||||||
|
* реальные размеры (best-effort — при сбое ридер читает файл до EOF, размеры
|
||||||
|
* 0xFFFFFFFF — конвенция стриминговых WAV).
|
||||||
|
*
|
||||||
|
* Не потокобезопасен — вызывать из одного потока (поток микрофона).
|
||||||
|
*/
|
||||||
|
class WavWriter(
|
||||||
|
private val path: File,
|
||||||
|
private val sampleRate: Int = 16_000,
|
||||||
|
private val channels: Int = 1,
|
||||||
|
) {
|
||||||
|
private val out: FileOutputStream
|
||||||
|
private var dataBytes = 0L
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
out = FileOutputStream(path)
|
||||||
|
out.write(headerBytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Дописать чанк s16le PCM (16 кГц, mono). */
|
||||||
|
fun write(pcm: ByteArray) {
|
||||||
|
out.write(pcm)
|
||||||
|
dataBytes += pcm.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрыть файл и прописать реальные размеры в заголовок (идемпотентно). */
|
||||||
|
fun close() {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
out.close()
|
||||||
|
runCatching {
|
||||||
|
RandomAccessFile(path, "rw").use { raf ->
|
||||||
|
raf.seek(4)
|
||||||
|
raf.write(leInt(36 + dataBytes)) // размер RIFF (little-endian)
|
||||||
|
raf.seek(40)
|
||||||
|
raf.write(leInt(dataBytes)) // размер data (little-endian)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4 байта little-endian (WAV-заголовок), в отличие от RandomAccessFile.writeInt (big-endian). */
|
||||||
|
private fun leInt(v: Long): ByteArray =
|
||||||
|
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()
|
||||||
|
|
||||||
|
private fun headerBytes(): ByteArray =
|
||||||
|
ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||||
|
put("RIFF".toByteArray())
|
||||||
|
putInt(-1) // placeholder
|
||||||
|
put("WAVE".toByteArray())
|
||||||
|
put("fmt ".toByteArray())
|
||||||
|
putInt(16)
|
||||||
|
putShort(1.toShort()) // PCM
|
||||||
|
putShort(channels.toShort())
|
||||||
|
putInt(sampleRate)
|
||||||
|
putInt(sampleRate * channels * 2)
|
||||||
|
putShort((channels * 2).toShort())
|
||||||
|
putShort(16.toShort())
|
||||||
|
put("data".toByteArray())
|
||||||
|
putInt(-1) // placeholder
|
||||||
|
}.array()
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package pw.binom.viewmate.glasses.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pw.binom.viewmate.glasses.ChatEntry
|
||||||
|
import pw.binom.viewmate.glasses.GlassesApp
|
||||||
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||||
|
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||||
|
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||||
|
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
||||||
|
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
||||||
|
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
||||||
|
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||||
|
* [GlassesApp.answerScrollDelta]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AssistantOverlay() {
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
val listening by app.listening.collectAsState()
|
||||||
|
val thinking by app.thinking.collectAsState()
|
||||||
|
val sttText by app.sttText.collectAsState()
|
||||||
|
val assistantText by app.assistantText.collectAsState()
|
||||||
|
val chatHistory by app.chatHistory.collectAsState()
|
||||||
|
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||||
|
if (!overlayVisible(state)) return
|
||||||
|
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||||
|
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||||
|
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val hasHistory = chatHistory.isNotEmpty()
|
||||||
|
|
||||||
|
// Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению.
|
||||||
|
LaunchedEffect(chatHistory.size, state.assistantText) {
|
||||||
|
if (hasHistory || state.assistantText.isNotBlank()) {
|
||||||
|
scrollState.animateScrollTo(scrollState.maxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
|
||||||
|
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
app.answerScrollDelta.collect { pending ->
|
||||||
|
if (pending == 0f) return@collect
|
||||||
|
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
||||||
|
app.answerScrollDelta.value = 0f
|
||||||
|
if (target != scrollState.value) {
|
||||||
|
scrollState.animateScrollTo(target, tween(150))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayBox(app, state, chatHistory, scrollState)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
||||||
|
@Composable
|
||||||
|
private fun overlayBox(
|
||||||
|
app: GlassesApp,
|
||||||
|
state: AssistantState,
|
||||||
|
chatHistory: List<ChatEntry>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
) {
|
||||||
|
Binocular(scale = false) {
|
||||||
|
val scale by app.scale.collectAsState()
|
||||||
|
val screenWidthPx = BinocularScreenSize.current.width
|
||||||
|
val screenHeightPx = BinocularScreenSize.current.height
|
||||||
|
val side = BinocularScreenSide.current
|
||||||
|
val containerW = (screenWidthPx * scale).toInt()
|
||||||
|
val containerH = (screenHeightPx * scale).toInt()
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(containerW.px)
|
||||||
|
.height(containerH.px)
|
||||||
|
.offset(
|
||||||
|
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||||
|
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
||||||
|
)
|
||||||
|
.background(Color.Black.copy(alpha = 0.72f)),
|
||||||
|
) {
|
||||||
|
AssistantOverlayContent(state, chatHistory, scrollState)
|
||||||
|
|
||||||
|
if (state.listening) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = 12.dp, end = 12.dp)
|
||||||
|
.size(16.dp)
|
||||||
|
.background(Color(0xFFFF3B30), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AssistantOverlayContent(
|
||||||
|
state: AssistantState,
|
||||||
|
chatHistory: List<ChatEntry>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
) {
|
||||||
|
val hasHistory = chatHistory.isNotEmpty()
|
||||||
|
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
||||||
|
val header = overlayHeader(state)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center,
|
||||||
|
) {
|
||||||
|
if (!header.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = header,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasHistory) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
chatHistory.forEach { entry ->
|
||||||
|
Text(
|
||||||
|
text = if (entry.role == "user") "я: ${entry.text}" else "ассистент: ${entry.text}",
|
||||||
|
color = if (entry.role == "user") Color.White else Color(0xFFB0BEC5),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fallbackAnswer) {
|
||||||
|
// Старый клиент телефона не шлёт историю: показываем одиночный ответ.
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "ассистент: ${state.assistantText}",
|
||||||
|
color = Color(0xFFB0BEC5),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ fun GlassesMovieScreen(
|
|||||||
var videoHeight by remember { mutableIntStateOf(480) }
|
var videoHeight by remember { mutableIntStateOf(480) }
|
||||||
|
|
||||||
val scale by GlassesApp.instance.scale.collectAsState()
|
val scale by GlassesApp.instance.scale.collectAsState()
|
||||||
|
val vertical by GlassesApp.instance.vertical.collectAsState()
|
||||||
|
|
||||||
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
|
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
|
||||||
// глаз из Binocular) регистрируют в нём свои SurfaceView.
|
// глаз из Binocular) регистрируют в нём свои SurfaceView.
|
||||||
@@ -103,14 +104,23 @@ fun GlassesMovieScreen(
|
|||||||
maxHeight = containerH - 25,
|
maxHeight = containerH - 25,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||||
|
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(containerW.px)
|
.width(containerW.px)
|
||||||
.height(containerH.px)
|
.height(containerH.px)
|
||||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
|
.offset(
|
||||||
|
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||||
|
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().background(Color.Black),
|
modifier = Modifier.fillMaxWidth().background(Color.Black),
|
||||||
|
// Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
|
||||||
|
// Row-центрирование съедало вертикаль — картинка оставалась «по центру».
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
|
||||||
) {
|
) {
|
||||||
if (SCALE_DEBUG_SQUARE) {
|
if (SCALE_DEBUG_SQUARE) {
|
||||||
@@ -121,16 +131,20 @@ fun GlassesMovieScreen(
|
|||||||
BinocularVideoPlayer(
|
BinocularVideoPlayer(
|
||||||
player = player,
|
player = player,
|
||||||
renderer = renderer,
|
renderer = renderer,
|
||||||
modifier = Modifier.size(playerWidth.px, playerHeight.px),
|
modifier = Modifier
|
||||||
|
.size(playerWidth.px, playerHeight.px)
|
||||||
|
.offset(y = videoOffsetY(containerH, playerHeight, vertical).px),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
|
|
||||||
// своей половины, бинокулярно сливается в одну по центру.
|
|
||||||
StatusPanelCompose(
|
StatusPanelCompose(
|
||||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
modifier = Modifier
|
||||||
scale = scale,
|
.align(Alignment.BottomCenter)
|
||||||
|
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +157,30 @@ fun GlassesMovieScreen(
|
|||||||
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
|
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
|
||||||
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
|
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вертикальное смещение контейнера глаза (с учётом масштаба):
|
||||||
|
* vertical = 1.0 — у верха (как всегда было); 0.0 — нижняя кромка контейнера
|
||||||
|
* прижата к низу экрана. Свободный ход по вертикали = (1 - scale) высоты.
|
||||||
|
*/
|
||||||
|
fun verticalOffsetY(screenHeightPx: Int, scale: Float, vertical: Float): Int =
|
||||||
|
((1f - vertical) * screenHeightPx * (1f - scale)).toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Смещение видео внутри контейнера глаза: vertical=1.0 — у верха контейнера,
|
||||||
|
* 0.0 — у низа (нижняя кромка видео прижата к низу контейнера, который сам
|
||||||
|
* у низа экрана при vertical=0). Row-центрирование иначе съедало вертикаль.
|
||||||
|
*/
|
||||||
|
fun videoOffsetY(containerHeightPx: Int, videoHeightPx: Int, vertical: Float): Int =
|
||||||
|
((1f - vertical) * (containerHeightPx - videoHeightPx).coerceAtLeast(0)).toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Горизонтальный сдвиг панели статуса к центру (к носу), чтобы её центр совпадал
|
||||||
|
* с центром контейнера при любом масштабе: LEFT — вправо, RIGHT — влево,
|
||||||
|
* на половину хода контейнера ((1 - scale) * W / 2).
|
||||||
|
*/
|
||||||
|
fun panelOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
|
||||||
|
((1f - scale) * screenWidthPx / 2f).toInt() * if (side == Side.LEFT) 1 else -1
|
||||||
|
|
||||||
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
|
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
|
||||||
private const val SCALE_DEBUG_SQUARE = false
|
private const val SCALE_DEBUG_SQUARE = false
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import pw.binom.viewmate.glasses.ui.Side
|
import pw.binom.viewmate.glasses.ui.Side
|
||||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||||
|
import pw.binom.viewmate.glasses.ui.verticalOffsetY
|
||||||
|
import pw.binom.viewmate.glasses.ui.videoOffsetY
|
||||||
|
import pw.binom.viewmate.glasses.ui.panelOffsetX
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
|
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
|
||||||
@@ -34,4 +37,81 @@ class ScaleOffsetTest {
|
|||||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
|
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
|
||||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
|
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* verticalOffsetY — вертикальное положение контейнера с учётом масштаба:
|
||||||
|
* 1.0 — у верха (offset 0), 0.0 — нижняя кромка у низа ((1 - scale) высоты).
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verticalTop_isZero() {
|
||||||
|
assertEquals(0, verticalOffsetY(480, 1f, 1f))
|
||||||
|
assertEquals(0, verticalOffsetY(480, 0.7f, 1f))
|
||||||
|
assertEquals(0, verticalOffsetY(480, 0.5f, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verticalBottom_pushesToBottomEdge() {
|
||||||
|
// 0.7 scale: свободный ход = 480 * 0.3 = 144
|
||||||
|
assertEquals(144, verticalOffsetY(480, 0.7f, 0f))
|
||||||
|
// 1.0 scale: хода нет — экран и так на весь дисплей
|
||||||
|
assertEquals(0, verticalOffsetY(480, 1f, 0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verticalMiddle_halfOfFreeRun() {
|
||||||
|
// 0.7 scale, vertical 0.5: (1-0.5)*480*0.3 = 72
|
||||||
|
assertEquals(72, verticalOffsetY(480, 0.7f, 0.5f))
|
||||||
|
assertEquals(0, verticalOffsetY(480, 1f, 0.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* videoOffsetY — видео внутри контейнера: верх при vertical=1, низ при 0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoTop_zero() {
|
||||||
|
assertEquals(0, videoOffsetY(432, 268, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoBottom_pushedToContainerBottom() {
|
||||||
|
// 432 контейнер, 268 видео: ход = 164
|
||||||
|
assertEquals(164, videoOffsetY(432, 268, 0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoMiddle_halfOfRun() {
|
||||||
|
assertEquals(82, videoOffsetY(432, 268, 0.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoLargerThanContainer_noNegativeOffset() {
|
||||||
|
assertEquals(0, videoOffsetY(200, 268, 0f), "видео выше контейнера — хода нет")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* panelOffsetX — панель статуса сдвигается к носу, как контейнер:
|
||||||
|
* LEFT — вправо, RIGHT — влево, на (1 - scale) * W / 2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun panelFullScale_noOffset() {
|
||||||
|
assertEquals(0, panelOffsetX(Side.LEFT, 640, 1f))
|
||||||
|
assertEquals(0, panelOffsetX(Side.RIGHT, 640, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun panelHalfScale_movesTowardNose() {
|
||||||
|
// 640 * 0.5 / 2 = 160: LEFT вправо, RIGHT влево
|
||||||
|
assertEquals(160, panelOffsetX(Side.LEFT, 640, 0.5f))
|
||||||
|
assertEquals(-160, panelOffsetX(Side.RIGHT, 640, 0.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun panelScale07_offset() {
|
||||||
|
// 640 * 0.3 / 2 = 96
|
||||||
|
assertEquals(96, panelOffsetX(Side.LEFT, 640, 0.7f))
|
||||||
|
assertEquals(-96, panelOffsetX(Side.RIGHT, 640, 0.7f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
package pw.binom.viewmate.glasses.gestures
|
||||||
|
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import com.rayneo.arsdk.android.touch.FlingArgs
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction.Click
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction.DoubleClick
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction.SlideBackward
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction.SlideContinuous
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction.SlideForward
|
||||||
|
import com.rayneo.arsdk.android.touch.TempleAction.TripleClick
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
||||||
|
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
|
||||||
|
*/
|
||||||
|
class AssistantGesturesTest {
|
||||||
|
|
||||||
|
private val movie = AssistantState()
|
||||||
|
private val listening = AssistantState(listening = true)
|
||||||
|
private val thinking = AssistantState(thinking = true)
|
||||||
|
private val answer = AssistantState(assistantText = "ответ")
|
||||||
|
|
||||||
|
// ————— режим по состоянию —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movieStateIsNone() {
|
||||||
|
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
|
||||||
|
@Test
|
||||||
|
fun listeningWinsOverEverything() {
|
||||||
|
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
|
||||||
|
assertEquals(AssistantMode.LISTENING, assistantMode(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
|
||||||
|
@Test
|
||||||
|
fun answerWinsOverThinking() {
|
||||||
|
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkingOnlyWhenNoAnswer() {
|
||||||
|
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— кино ( NONE) —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movieMatrix() {
|
||||||
|
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), movie))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
|
||||||
|
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
|
||||||
|
assertEquals(
|
||||||
|
GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 30_000),
|
||||||
|
gestureDecision(slideForward(), movie),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
GestureDecision.Seek(MediaCommand.SEEK_BACKWARD, 30_000),
|
||||||
|
gestureDecision(slideBackwardAction(), movie),
|
||||||
|
)
|
||||||
|
// Свайп «плавного» типа без ответа не управляет скроллом → игнор.
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), movie))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movieSeekStepIsParameter() {
|
||||||
|
val d = gestureDecision(slideForward(), movie, seekStepMs = 10_000)
|
||||||
|
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— слушаю —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listeningMatrix() {
|
||||||
|
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
|
||||||
|
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), listening))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkingMatrix() {
|
||||||
|
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), thinking))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), thinking))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), thinking))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), thinking))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— «ответ виден» —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun answerMatrix() {
|
||||||
|
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
|
||||||
|
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
|
||||||
|
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
|
||||||
|
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
|
||||||
|
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
|
||||||
|
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun answerPageDeltaIsParameter() {
|
||||||
|
assertEquals(
|
||||||
|
GestureDecision.ScrollAnswer(80f),
|
||||||
|
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionTransitions() {
|
||||||
|
var s = movie
|
||||||
|
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
|
||||||
|
s = s.copy(listening = true)
|
||||||
|
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
|
||||||
|
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
|
||||||
|
assertEquals(AssistantMode.THINKING, assistantMode(s))
|
||||||
|
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||||
|
s = s.copy(thinking = false, assistantText = "ответ")
|
||||||
|
assertEquals(AssistantMode.ANSWER, assistantMode(s))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||||
|
// Выход: оверлей невидим, кино снова управляется кликом.
|
||||||
|
val exited = s.copy(assistantText = "")
|
||||||
|
assertFalse(overlayVisible(exited))
|
||||||
|
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
|
||||||
|
@Test
|
||||||
|
fun cancelStaysInConversation() {
|
||||||
|
val decision = gestureDecision(DoubleClick(), listening)
|
||||||
|
assertEquals(GestureDecision.CancelDictation, decision)
|
||||||
|
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick.
|
||||||
|
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
|
||||||
|
assertEquals(AssistantMode.ANSWER, assistantMode(after))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— оверлей —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun overlayInvisibleInMovie() {
|
||||||
|
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
|
||||||
|
assertFalse(overlayVisible(movie))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun overlayVisibleInListeningThinkingAndAnswer() {
|
||||||
|
assertTrue(overlayVisible(listening))
|
||||||
|
assertTrue(overlayVisible(thinking))
|
||||||
|
assertTrue(overlayVisible(answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkingHeaderIsDotDotDot() {
|
||||||
|
assertEquals("думаю...", overlayHeader(thinking))
|
||||||
|
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
|
||||||
|
assertEquals(
|
||||||
|
"думаю...",
|
||||||
|
overlayHeader(AssistantState(listening = true, thinking = true)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listeningHeaderFallsBackToListening() {
|
||||||
|
assertEquals("слушаю...", overlayHeader(listening))
|
||||||
|
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun answerHeaderIsLastPhraseOrNull() {
|
||||||
|
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
|
||||||
|
assertNull(overlayHeader(answer))
|
||||||
|
assertNull(overlayHeader(movie))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— скролл ответа —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollOffsetAppliesDelta() {
|
||||||
|
assertEquals(342, scrollOffset(132, 210f, 10_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollOffsetClampsAboveMax() {
|
||||||
|
assertEquals(1000, scrollOffset(800, 1000f, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollOffsetClampsBelowZero() {
|
||||||
|
assertEquals(0, scrollOffset(100, -500f, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Float-дельта округляется вниз (к целой части), а не к ближайшему.
|
||||||
|
* (10.7f в IEEE754 ~ 10.700000763, поэтому 100 - 10.7f = 89.2999… → 89.)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun scrollOffsetTruncatesFractionalDelta() {
|
||||||
|
assertEquals(112, scrollOffset(100, 12.7f, 10_000))
|
||||||
|
assertEquals(89, scrollOffset(100, -10.7f, 10_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollOffsetWithZeroMax() {
|
||||||
|
assertEquals(0, scrollOffset(5, 100f, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK-жесты в юнит-тестах: MotionEvent создаётся через sun.misc.Unsafe
|
||||||
|
* (allocateInstance, без конструктора — в JVM-тестах Android-классы «not mocked»).
|
||||||
|
* Как в GestureCommandsTest.
|
||||||
|
*/
|
||||||
|
private fun fakeMotionEvent(): MotionEvent {
|
||||||
|
val unsafeClass = Class.forName("sun.misc.Unsafe")
|
||||||
|
val theUnsafe = unsafeClass.getDeclaredField("theUnsafe")
|
||||||
|
theUnsafe.isAccessible = true
|
||||||
|
val unsafe = theUnsafe.get(null)
|
||||||
|
val allocate = unsafeClass.getMethod("allocateInstance", Class::class.java)
|
||||||
|
return allocate.invoke(unsafe, MotionEvent::class.java) as MotionEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unsafeFlingArgs(): FlingArgs {
|
||||||
|
val me1 = fakeMotionEvent()
|
||||||
|
val me2 = fakeMotionEvent()
|
||||||
|
return FlingArgs(me1, me2, 1f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun slideForward() = SlideForward(unsafeFlingArgs())
|
||||||
|
|
||||||
|
private fun slideBackwardAction() = SlideBackward(unsafeFlingArgs())
|
||||||
|
|
||||||
|
/** SlideContinuous: дельта шлейфа; second — флаг long-жеста (на тест не влияет). */
|
||||||
|
private fun slideContinuous(delta: Float = 9f) = SlideContinuous(delta, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotSame
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
|
||||||
|
* 16-бит моно: кадр = 2 байта. Старики брали return read() как байты —
|
||||||
|
* и copyOf(кадры) резал каждый 100-мс кусок пополам (терялись 2-е 50 мс).
|
||||||
|
*/
|
||||||
|
class MicChunkTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullChunk_all1600Frames_returnsAll3200Bytes() {
|
||||||
|
val buf = ByteArray(CHUNK_BYTES)
|
||||||
|
val out = micChunkBytes(buf, CHUNK_BYTES / 2) // 1600 кадров = полная 100 мс
|
||||||
|
assertEquals(CHUNK_BYTES, out.size, "полный чанк = 3200 байт (100 мс), а не 1600")
|
||||||
|
assertSame(buf, out, "полный чанк не копируется — реюзаем buf без аллокаций")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortRead_800Frames_gives1600Bytes_not800() {
|
||||||
|
val buf = ByteArray(CHUNK_BYTES)
|
||||||
|
val out = micChunkBytes(buf, 800) // 800 кадров = 1600 байт
|
||||||
|
assertEquals(1_600, out.size, "байты = кадры × 2, не равны количеству кадров")
|
||||||
|
assertNotSame(buf, out, "короткий чанк копируется, иначе хвост buf — мусор")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oldLogic_lostSecondHalf_ofEachChunk() {
|
||||||
|
// Задокументированный баг: n (кадры) брался как байты.
|
||||||
|
val buf = ByteArray(CHUNK_BYTES)
|
||||||
|
val frames = recorderWouldReturn(1600) // полный 100-мс запрос вернул 1600 кадров
|
||||||
|
val oldChunk = if (frames == buf.size) buf else buf.copyOf(frames) // СТАРОЕ
|
||||||
|
assertEquals(1_600, oldChunk.size, "старая логика теряла 2-ю половину куска — отсюда «как через один»")
|
||||||
|
val newChunk = micChunkBytes(buf, frames)
|
||||||
|
assertEquals(3_200, newChunk.size, "новая логика отдаёт полные 100 мс")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun framesRequested_forFull100MsIs1600() {
|
||||||
|
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
|
||||||
|
* gainChunk ×5 поднимает речь для whisper; VAD детектит на сыром сигнале.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun quietChunk_gained3_peakReachesAtLeast2700_sizeKept() {
|
||||||
|
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
|
||||||
|
val out = gainChunk(chunk)
|
||||||
|
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
||||||
|
val peak = s16Samples(out).maxOf { abs(it) }
|
||||||
|
assertTrue(peak >= 2_700, "тихий пик 1000 × 3 должен дать ≥ 2700, а не $peak")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loudChunk_gained3_clampedInsideS16Range() {
|
||||||
|
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
|
||||||
|
val out = gainChunk(chunk)
|
||||||
|
val s = s16Samples(out)
|
||||||
|
assertEquals(32_767, s.max(), "30000 × 3 = 90000 → насыщение до 32767")
|
||||||
|
assertEquals(-32_768, s.min(), "-30000 × 3 = -90000 → насыщение до -32768")
|
||||||
|
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun zeroChunk_gained3_bytesUnchanged() {
|
||||||
|
val chunk = ByteArray(CHUNK_BYTES)
|
||||||
|
val out = gainChunk(chunk)
|
||||||
|
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
||||||
|
assertTrue(out.all { it == 0.toByte() }, "нули остаются нулями после усиления")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gainEqualsOne_returnsSameInstance_noReallocation() {
|
||||||
|
val chunk = s16leChunk(1_600) { j -> (j % 3_001) - 1_500 }
|
||||||
|
assertSame(chunk, gainChunk(chunk, 1f), "gain = 1: не аллоцируем заново — возвратим сами bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
||||||
|
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
||||||
|
val s = s16Samples(gainChunk(chunk))
|
||||||
|
assertEquals(-369, s[0], "первый сэмпл: знак сохранён и усилен ×3")
|
||||||
|
assertEquals(1_368, s[1], "второй сэмпл: порядок сохранён и усилен ×3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
||||||
|
private fun s16leChunk(n: Int, make: (Int) -> Int): ByteArray {
|
||||||
|
val out = ByteArray(n * 2)
|
||||||
|
for (j in 0 until n) {
|
||||||
|
val v = make(j)
|
||||||
|
out[j * 2] = (v and 0xff).toByte()
|
||||||
|
out[j * 2 + 1] = ((v shr 8) and 0xff).toByte()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** s16le ByteArray → IntArray сэмплов (разбор как в s16leToFloat app-phone). */
|
||||||
|
private fun s16Samples(bytes: ByteArray): IntArray {
|
||||||
|
val n = bytes.size / 2
|
||||||
|
val out = IntArray(n)
|
||||||
|
for (j in 0 until n) {
|
||||||
|
val lo = bytes[j * 2].toInt() and 0xff
|
||||||
|
val hi = bytes[j * 2 + 1].toInt() and 0xff
|
||||||
|
var s = lo or (hi shl 8)
|
||||||
|
if (s >= 0x8000) s -= 0x10000
|
||||||
|
out[j] = s
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recorderWouldReturn(frames: Int) = frames
|
||||||
|
|
||||||
|
/*
|
||||||
|
* VAD на очках (схема pair-view): фреймы 20 мс (320 сэмплов @ 16 кГц),
|
||||||
|
* усиление ×10 перед VAD и whisper, склейка речевых сегментов в s16le.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gainShort_quietSamples_boosted5_saturated() {
|
||||||
|
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
|
||||||
|
val out = gainShort(samples, 5f)
|
||||||
|
assertEquals(samples.size, out.size)
|
||||||
|
assertEquals(5_000, out[0].toInt(), "1000 × 5")
|
||||||
|
assertEquals(-5_000, out[1].toInt(), "-1000 × 5")
|
||||||
|
assertEquals(32_767, out[2].toInt(), "20000 × 5 → насыщение")
|
||||||
|
assertEquals(-32_768, out[3].toInt(), "-32768 × 5 → насыщение")
|
||||||
|
assertEquals(0, out[4].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gainShort_gain1_returnsSameArray() {
|
||||||
|
val samples = shortArrayOf(1, 2, 3)
|
||||||
|
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downsample48To16_thirdLengthAndFIR() {
|
||||||
|
// 9 сэмплов 48 кГц (3 фрейма по 3) → 3 сэмпла 16 кГц
|
||||||
|
val frames = listOf(shortArrayOf(3, 6, 9), shortArrayOf(12, 15, 18), shortArrayOf(21, 24, 27))
|
||||||
|
val out = downsample48To16(frames)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
assertEquals(3, out[0].size, "длина = 1/3")
|
||||||
|
// тройки (3,6,9) → 6; (12,15,18) → 15; (21,24,27) → 24
|
||||||
|
assertEquals(6, out[0][0].toInt())
|
||||||
|
assertEquals(15, out[0][1].toInt())
|
||||||
|
assertEquals(24, out[0][2].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downsample48To16_shortSegment_keepsFloor() {
|
||||||
|
// 5 сэмплов → 1 (5/3 = 1)
|
||||||
|
val out = downsample48To16(listOf(shortArrayOf(1, 2, 3, 4, 5)))
|
||||||
|
assertEquals(1, out[0].size)
|
||||||
|
assertEquals(2, out[0][0].toInt(), "среднее (1,2,3)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortsToPcm16_concatenatesLittleEndian() {
|
||||||
|
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
|
||||||
|
val out = shortsToPcm16(frames)
|
||||||
|
assertEquals(6, out.size, "3 сэмпла × 2 байта")
|
||||||
|
assertEquals(1, out[0].toInt() and 0xff)
|
||||||
|
assertEquals(0, out[1].toInt())
|
||||||
|
assertEquals(2, out[2].toInt() and 0xff)
|
||||||
|
assertEquals(0, out[3].toInt())
|
||||||
|
assertEquals(3, out[4].toInt() and 0xff)
|
||||||
|
assertEquals(0, out[5].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortsToPcm16_negativeSample_twoBytesComplement() {
|
||||||
|
val out = shortsToPcm16(listOf(shortArrayOf(-1)))
|
||||||
|
assertEquals(0xff, out[0].toInt() and 0xff)
|
||||||
|
assertEquals(0xff, out[1].toInt() and 0xff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortsToPcm16_empty_returnsEmpty() {
|
||||||
|
assertEquals(0, shortsToPcm16(emptyList()).size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регрессия: размеры в WAV-заголовке должны быть little-endian — из-за big-endian
|
||||||
|
* (RandomAccessFile.writeInt) плееры не открывали дампы stt_dump_*.wav:
|
||||||
|
* для файла 188844 байта байты 4..8 были 00 02 e1 a4 вместо a4 e1 02 00.
|
||||||
|
*/
|
||||||
|
class WavWriterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun close_3200Data_riffAndDataSizesLittleEndian() {
|
||||||
|
val bytes = writeAndClose(3_200)
|
||||||
|
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
|
||||||
|
"RIFF size = 36 + data, little-endian (байты 4..8) — не big-endian из writeInt")
|
||||||
|
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44),
|
||||||
|
"data size = 3200, little-endian (байты 40..44)")
|
||||||
|
assertEquals(1_600, leIntAt(bytes, 40) / 2,
|
||||||
|
"nframes = data/2 (16 кГц s16le mono) — как считает python wave")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun close_noData_riffSize36DataZero() {
|
||||||
|
val bytes = writeAndClose(0)
|
||||||
|
assertContentEquals(leIntBytes(36), bytes.copyOfRange(4, 8), "пустой data: RIFF size = 36")
|
||||||
|
assertEquals(0, leIntAt(bytes, 40), "пустой data: data size = 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun closeTwice_sizesUnchanged() {
|
||||||
|
val file = tmpFile()
|
||||||
|
val w = WavWriter(file)
|
||||||
|
w.write(ByteArray(3_200))
|
||||||
|
w.close()
|
||||||
|
w.close()
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
assertEquals(44 + 3_200, bytes.size, "второй close ничего не дописывает в файл")
|
||||||
|
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
|
||||||
|
"двойной close не портит размеры (флаг closed)")
|
||||||
|
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeAndClose(dataBytes: Int): ByteArray {
|
||||||
|
val file = tmpFile()
|
||||||
|
val w = WavWriter(file)
|
||||||
|
if (dataBytes > 0) w.write(ByteArray(dataBytes))
|
||||||
|
w.close()
|
||||||
|
assertEquals(44L + dataBytes, file.length(), "файл = 44-байтный заголовок + data")
|
||||||
|
return file.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tmpFile(): File {
|
||||||
|
val f = Files.createTempFile("wvwriter-test", ".wav").toFile()
|
||||||
|
f.deleteOnExit()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4 байта little-endian — ожидаемый вид записи в WAV-заголовке. */
|
||||||
|
private fun leIntBytes(v: Int): ByteArray =
|
||||||
|
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array()
|
||||||
|
|
||||||
|
/** Прочитать 4 байта little-endian с позиции offset (то, как rидер получит int32). */
|
||||||
|
private fun leIntAt(bytes: ByteArray, offset: Int): Int {
|
||||||
|
fun b(i: Int) = bytes[i].toInt() and 0xff
|
||||||
|
return b(offset) or (b(offset + 1) shl 8) or (b(offset + 2) shl 16) or (b(offset + 3) shl 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,10 +94,16 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(project(":lib-core"))
|
implementation(project(":lib-core"))
|
||||||
|
|
||||||
|
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
|
||||||
|
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
|
||||||
|
|
||||||
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||||
implementation(libs.koog.openai.client)
|
implementation(libs.koog.openai.client)
|
||||||
implementation(libs.koog.http.client.okhttp)
|
implementation(libs.koog.http.client.okhttp)
|
||||||
|
|
||||||
|
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU)
|
||||||
|
implementation(libs.litertlm.android)
|
||||||
|
|
||||||
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
||||||
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
||||||
|
|
||||||
@@ -108,6 +114,8 @@ dependencies {
|
|||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
|
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
|
||||||
|
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
|
// Тестовый стриминговый VAD (Silero на ONNX, JVM) — из mavenLocal
|
||||||
|
testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT")
|
||||||
testImplementation("io.ktor:ktor-server-test-host:3.3.0")
|
testImplementation("io.ktor:ktor-server-test-host:3.3.0")
|
||||||
testImplementation("io.ktor:ktor-client-websockets:3.3.0")
|
testImplementation("io.ktor:ktor-client-websockets:3.3.0")
|
||||||
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
|
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -7,7 +7,18 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<!-- Bluetooth SPP-транспорт (включается флагом PhoneConfig.USE_BLUETOOTH):
|
||||||
|
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
|
||||||
|
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".PhoneApp"
|
android:name=".PhoneApp"
|
||||||
@@ -16,13 +27,28 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<!-- NOTE 2026-08-23 (porfiry): точь-в-точь как у автора — ВНУТРИ <application>
|
||||||
|
(AndroidManifest.xml автора, строки 59-61: три uses-native-library).
|
||||||
|
Системные libOpenCL.so (Adreno) + libcdsprpc.so (Qualcomm-расчёты)
|
||||||
|
видны из app-namespace; без них LiteRT-LM падает на
|
||||||
|
"Can not find OpenCL library on this device". -->
|
||||||
|
<uses-native-library android:name="libvndksupport.so" android:required="false"/>
|
||||||
|
<uses-native-library android:name="libOpenCL.so" android:required="false"/>
|
||||||
|
<uses-native-library android:name="libcdsprpc.so" android:required="false"/>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".PlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothServerSocket
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.viewmate.core.log
|
||||||
|
import pw.binom.viewmate.core.net.BtTransportConstants
|
||||||
|
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bluetooth-транспорт, сторона телефона («сервер») — TASK-transport.md п.3:
|
||||||
|
* RFCOMM/SPP ([BtTransportConstants.SPP_UUID]), сообщения — строки
|
||||||
|
* (JSON + перевод строки как разделитель), read-поток — на Dispatchers.IO,
|
||||||
|
* запись — под синхронизацией на сокете. [connId] = MAC-адрес очков.
|
||||||
|
* Работает флагом [pw.binom.viewmate.phone.PhoneConfig.USE_BLUETOOTH]
|
||||||
|
* (дефолт — [WifiServerTransport]).
|
||||||
|
* @param context — для runtime-проверки BT-пермишенов (Android 12+ / до 12).
|
||||||
|
*/
|
||||||
|
class BtServerTransport(
|
||||||
|
private val context: Context? = null,
|
||||||
|
) : GlassesServerTransport {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||||
|
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||||
|
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||||
|
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||||
|
*/
|
||||||
|
fun runtimePermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||||
|
runtimePermissions().all {
|
||||||
|
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name = "bluetooth"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
@Volatile
|
||||||
|
private var serverSocket: BluetoothServerSocket? = null
|
||||||
|
|
||||||
|
private val sockets = ConcurrentHashMap<String, BluetoothSocket>()
|
||||||
|
private val writers = ConcurrentHashMap<String, OutputStream>()
|
||||||
|
private val readerJobs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
|
||||||
|
private val readerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
/** Бесконечно принимать SPP-соединения очков; без BT/пермишена — ретраи через 3с. */
|
||||||
|
override suspend fun accept(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
while (!closed) {
|
||||||
|
if (serverSocket == null) {
|
||||||
|
serverSocket = openServerSocket()
|
||||||
|
if (serverSocket == null) {
|
||||||
|
log("bt", "SPP-сервер не создан (BT выключен? нет пермишена?), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log("bt", "SPP-сервер запущен: ${BtTransportConstants.SERVICE_NAME} (${BtTransportConstants.SPP_UUID})")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val s = withContext(Dispatchers.IO) { serverSocket!!.accept() } ?: break
|
||||||
|
if (closed) {
|
||||||
|
runCatching { s.close() }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val remote: BluetoothDevice = s.remoteDevice
|
||||||
|
val connId = remote.address
|
||||||
|
sockets[connId] = s
|
||||||
|
writers[connId] = s.outputStream
|
||||||
|
log("bt", "очки подключились: $connId")
|
||||||
|
onConnected(connId)
|
||||||
|
readerJobs[connId] = readerScope.launch {
|
||||||
|
readLines(s, connId, onMessage, onDisconnected)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "accept прерван (${e::class.simpleName}: ${e.message}), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||||
|
runCatching { serverSocket?.close() }
|
||||||
|
serverSocket = null
|
||||||
|
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupConnections()
|
||||||
|
readerScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SPP-сервер (RFCOMM-канал SPP): без BT-радио/пермишена — null (лечь и ретраиться). */
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun openServerSocket(): BluetoothServerSocket? {
|
||||||
|
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
|
||||||
|
log("bt", "BT-радио не найдено — SPP недоступен")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!adapter.isEnabled) {
|
||||||
|
log("bt", "Bluetooth выключен — включите в настройках Android")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val ctx = context
|
||||||
|
if (ctx != null && !hasBtPermissions(ctx)) {
|
||||||
|
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
adapter.listenUsingRfcommWithServiceRecord(
|
||||||
|
BtTransportConstants.SERVICE_NAME,
|
||||||
|
UUID.fromString(BtTransportConstants.SPP_UUID),
|
||||||
|
)
|
||||||
|
}.onFailure { log("bt", "не удалось создать SPP-сервер: ${it.message}") }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
|
||||||
|
private suspend fun readLines(
|
||||||
|
s: BluetoothSocket,
|
||||||
|
connId: String,
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.inputStream))
|
||||||
|
try {
|
||||||
|
while (!closed) {
|
||||||
|
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
|
||||||
|
if (line.isNotBlank()) onMessage(connId, line)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("bt", "read-поток $connId оборван: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
runCatching { reader.close() }
|
||||||
|
runCatching { s.close() }
|
||||||
|
sockets.remove(connId)
|
||||||
|
writers.remove(connId)
|
||||||
|
readerJobs.remove(connId)
|
||||||
|
}
|
||||||
|
if (!closed) {
|
||||||
|
log("bt", "очки отключились: $connId")
|
||||||
|
onDisconnected(connId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-строка в сокет [connId]: UTF-8 + разделитель, под синхронизацией на сокете. */
|
||||||
|
override suspend fun send(connId: String, json: String) {
|
||||||
|
val out = writers[connId] ?: return
|
||||||
|
runCatching {
|
||||||
|
synchronized(out) {
|
||||||
|
out.write(json.toByteArray(Charsets.UTF_8))
|
||||||
|
out.write(BtTransportConstants.NEWLINE.code)
|
||||||
|
out.flush()
|
||||||
|
}
|
||||||
|
}.onFailure { log("bt", "запись на $connId не удалась: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
runCatching { serverSocket?.close() }
|
||||||
|
serverSocket = null
|
||||||
|
cleanupConnections()
|
||||||
|
readerScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupConnections() {
|
||||||
|
readerJobs.values.forEach { it.cancel() }
|
||||||
|
readerJobs.clear()
|
||||||
|
sockets.values.forEach { runCatching { it.close() } }
|
||||||
|
sockets.clear()
|
||||||
|
writers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import com.google.ai.edge.litertlm.Backend
|
||||||
|
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||||
|
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отладочный HTTP-сервер в приложении (только для разработки).
|
||||||
|
* Позволяет дёргать ассистента и переключать модель без UI:
|
||||||
|
*
|
||||||
|
* GET /status — выбор модели, готовность движка, размер файла
|
||||||
|
* POST /chat {"text":"привет"} — отправить фразу в ассистента (та же очередь, что UI)
|
||||||
|
* POST /model {"choice":"local"} — переключить модель (local|remote), персист
|
||||||
|
*
|
||||||
|
* Запускается на 127.0.0.1:8787. Доступ: `adb forward tcp:8787 tcp:8787`.
|
||||||
|
*/
|
||||||
|
object DebugHttpServer {
|
||||||
|
|
||||||
|
private const val PORT = 8787
|
||||||
|
private const val TAG = "httpd"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var running = false
|
||||||
|
|
||||||
|
fun start(app: PhoneApp) {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val server = ServerSocket(PORT, 8, InetAddress.getByName("127.0.0.1"))
|
||||||
|
log(TAG, "debug-http на 127.0.0.1:$PORT (adb forward tcp:8787 tcp:8787)")
|
||||||
|
while (running) {
|
||||||
|
try {
|
||||||
|
val socket = server.accept()
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
handle(socket, app)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, "обработка запроса: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
runCatching { socket.close() }
|
||||||
|
}
|
||||||
|
}.apply { isDaemon = true }.start()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (running) log(TAG, "accept: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, "не удалось запустить: ${e.message}")
|
||||||
|
}
|
||||||
|
}.apply { isDaemon = true; name = "viewmate-httpd" }.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handle(socket: java.net.Socket, app: PhoneApp) {
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val requestLine = reader.readLine() ?: return
|
||||||
|
val parts = requestLine.split(" ")
|
||||||
|
if (parts.size < 2) return
|
||||||
|
val method = parts[0]
|
||||||
|
val rawPath = parts[1]
|
||||||
|
val path = rawPath.substringBefore("?")
|
||||||
|
val queryParams = rawPath.substringAfter("?", "").split("&")
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.associate { it.substringBefore("=") to it.substringAfter("=") }
|
||||||
|
|
||||||
|
// Тело (до Content-Length)
|
||||||
|
var contentLength = 0
|
||||||
|
while (true) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
if (line.isEmpty()) break
|
||||||
|
if (line.startsWith("Content-Length:", true)) {
|
||||||
|
contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = if (contentLength > 0) {
|
||||||
|
val buf = CharArray(contentLength)
|
||||||
|
var read = 0
|
||||||
|
while (read < contentLength) {
|
||||||
|
val n = reader.read(buf, read, contentLength - read)
|
||||||
|
if (n < 0) break
|
||||||
|
read += n
|
||||||
|
}
|
||||||
|
String(buf, 0, read)
|
||||||
|
} else ""
|
||||||
|
|
||||||
|
val response: String
|
||||||
|
val code: Int
|
||||||
|
when {
|
||||||
|
method == "GET" && path.startsWith("/status") -> {
|
||||||
|
val choice = app.llmChoice.value
|
||||||
|
val local = app.localLlmClient()
|
||||||
|
val file = app.localModelFile()
|
||||||
|
response = """{"choice":"${choice.name.lowercase()}","local":{${localStatus(local, file)}}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/chat") -> {
|
||||||
|
val text = jsonField(body, "text") ?: ""
|
||||||
|
if (text.isBlank()) {
|
||||||
|
response = """{"error":"пустой text"}"""
|
||||||
|
code = 400
|
||||||
|
} else {
|
||||||
|
app.sendToAssistant(text)
|
||||||
|
response = """{"ok":true,"queued":"${text.take(60)}"}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/model") -> {
|
||||||
|
val choice = jsonField(body, "choice")
|
||||||
|
if (choice == "local" || choice == "remote") {
|
||||||
|
app.setLlmModelChoice(if (choice == "local") LlmModelChoice.LOCAL else LlmModelChoice.REMOTE)
|
||||||
|
response = """{"ok":true,"choice":"$choice"}"""
|
||||||
|
code = 200
|
||||||
|
} else {
|
||||||
|
response = """{"error":"choice должен быть local|remote"}"""
|
||||||
|
code = 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
|
||||||
|
method == "GET" && path.startsWith("/mercury/status") -> {
|
||||||
|
val s = MercuryBridge.state.value
|
||||||
|
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/connect") -> {
|
||||||
|
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
|
||||||
|
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/disconnect") -> {
|
||||||
|
kotlinx.coroutines.runBlocking { MercuryBridge.disconnect() }
|
||||||
|
response = """{"ok":true}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/screen/off") -> {
|
||||||
|
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOff() }
|
||||||
|
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/screen/on") -> {
|
||||||
|
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOn() }
|
||||||
|
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/autolock") -> {
|
||||||
|
val time = queryParams["time"]?.toIntOrNull() ?: 10
|
||||||
|
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.autoLockTime(time) }
|
||||||
|
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
response = """{"error":"нет такого пути: $method $path"}"""
|
||||||
|
code = 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = socket.getOutputStream()
|
||||||
|
val payload = response.toByteArray(Charsets.UTF_8)
|
||||||
|
out.write(("HTTP/1.1 $code ${reason(code)}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8))
|
||||||
|
out.write(payload)
|
||||||
|
out.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localStatus(local: LocalLlmClient?, file: java.io.File): String {
|
||||||
|
if (local == null) return """"ready":false,"fileExists":${file.exists()},"fileBytes":${file.length()}"""
|
||||||
|
return """"ready":true,"modelDownloaded":${local.modelDownloaded()},"fileExists":${file.exists()},"fileBytes":${file.length()}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonField(body: String, key: String): String? {
|
||||||
|
// {"text":"..."} — примитивный парсер, без кавычек внутри значения
|
||||||
|
val regex = Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"")
|
||||||
|
return regex.find(body)?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonEscape(s: String): String =
|
||||||
|
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
|
||||||
|
|
||||||
|
private fun reason(code: Int): String = when (code) {
|
||||||
|
200 -> "OK"
|
||||||
|
400 -> "Bad Request"
|
||||||
|
404 -> "Not Found"
|
||||||
|
else -> "Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
package pw.binom.viewmate.phone
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
import io.ktor.server.application.Application
|
import android.content.Context
|
||||||
import io.ktor.server.application.install
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import io.ktor.server.cio.CIO
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
import kotlinx.coroutines.Job
|
||||||
import io.ktor.server.engine.embeddedServer
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import io.ktor.server.routing.routing
|
import kotlinx.coroutines.cancel
|
||||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
|
||||||
import io.ktor.server.websocket.WebSockets
|
|
||||||
import io.ktor.server.websocket.webSocket
|
|
||||||
import io.ktor.websocket.Frame
|
|
||||||
import io.ktor.websocket.readText
|
|
||||||
import io.ktor.websocket.send
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import pw.binom.viewmate.core.GlassesMode
|
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||||
import pw.binom.viewmate.core.phone.GlassesSender
|
import pw.binom.viewmate.core.phone.GlassesSender
|
||||||
import pw.binom.viewmate.core.phone.PhoneState
|
import pw.binom.viewmate.core.phone.PhoneState
|
||||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||||
@@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
|
||||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
|
||||||
* DownloadProgress → карта скачивания на очки).
|
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
|
||||||
|
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
|
||||||
*/
|
*/
|
||||||
class GlassesHub(
|
class GlassesHub(
|
||||||
private val state: PhoneState,
|
private val state: PhoneState,
|
||||||
|
/** Выбранный транспорт (дефолт — [WifiServerTransport]). */
|
||||||
|
private val transport: GlassesServerTransport,
|
||||||
) {
|
) {
|
||||||
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
|
/** Сессии: connId → Unit. */
|
||||||
|
private val sessions = ConcurrentHashMap<String, Unit>()
|
||||||
|
|
||||||
private val _connected = MutableStateFlow(0)
|
private val _connected = MutableStateFlow(0)
|
||||||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||||||
@@ -100,42 +99,47 @@ class GlassesHub(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
||||||
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s
|
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
|
||||||
* рассылаются через [broadcast].
|
* рассылаются через [broadcast].
|
||||||
*/
|
*/
|
||||||
@Volatile
|
@Volatile
|
||||||
var stt: SttStreamer? = null
|
var stt: SttStreamer? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
||||||
|
* на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
|
||||||
|
* отсутствуют — только полный текст (по клику или по тишине 30 с).
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var onStopFullText: ((String) -> Unit)? = null
|
||||||
|
|
||||||
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
||||||
@Volatile
|
@Volatile
|
||||||
var sttFactory: (() -> SttStreamer?)? = null
|
var sttFactory: (() -> SttStreamer?)? = null
|
||||||
|
|
||||||
fun add(session: DefaultWebSocketServerSession) {
|
fun add(connId: String) {
|
||||||
sessions[session] = Unit
|
sessions[connId] = Unit
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
log("hub", "очки подключились, всего: ${sessions.size}")
|
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(session: DefaultWebSocketServerSession) {
|
fun remove(connId: String) {
|
||||||
sessions.remove(session)
|
sessions.remove(connId)
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
log("hub", "очки отключились, всего: ${sessions.size}")
|
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отправить сообщение всем подключённым очкам. */
|
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
|
||||||
suspend fun broadcast(msg: HostToGlasses) {
|
suspend fun broadcast(msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
for (session in sessions.keys) {
|
for (connId in sessions.keys) {
|
||||||
try {
|
runCatching { transport.send(connId, text) }
|
||||||
session.send(text)
|
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||||
} catch (e: Exception) {
|
|
||||||
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обработать входящее текстовое сообщение от очков. */
|
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
|
||||||
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
|
suspend fun handle(connId: String, text: String) {
|
||||||
val msg = try {
|
val msg = try {
|
||||||
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -144,8 +148,8 @@ class GlassesHub(
|
|||||||
}
|
}
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is Hello -> {
|
is Hello -> {
|
||||||
log("glasses", "hello (app=${msg.appVersion}) → welcome")
|
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
|
||||||
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
is Gesture -> {
|
is Gesture -> {
|
||||||
@@ -199,80 +203,76 @@ class GlassesHub(
|
|||||||
is StopStt -> {
|
is StopStt -> {
|
||||||
if (msg.cancel) {
|
if (msg.cancel) {
|
||||||
log("stt", "распознавание отменено (очки)")
|
log("stt", "распознавание отменено (очки)")
|
||||||
send(session, SttCancel())
|
send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
} else {
|
} else {
|
||||||
val full = stt?.finish() ?: ""
|
val full = stt?.finish() ?: ""
|
||||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||||
send(session, SttDone(full))
|
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
|
if (full.isNotBlank()) {
|
||||||
|
send(connId, SttDone(full))
|
||||||
|
val phrase = full.trim()
|
||||||
|
log("stt", "фраза в LLM (по клику): $phrase")
|
||||||
|
onStopFullText?.invoke(phrase)
|
||||||
|
} else {
|
||||||
|
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
|
||||||
|
log("stt", "фразы не было — очки в «слушаю»")
|
||||||
|
broadcast(SttCancel(reason = SttCancel.REASON_USER))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
|
private suspend fun send(connId: String, msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
session.send(text)
|
runCatching { transport.send(connId, text) }
|
||||||
}
|
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||||
}
|
|
||||||
|
|
||||||
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
|
|
||||||
fun Application.glassesServerModule(hub: GlassesHub) {
|
|
||||||
install(WebSockets) {
|
|
||||||
pingPeriodMillis = 5_000
|
|
||||||
timeoutMillis = 5_000
|
|
||||||
maxFrameSize = Long.MAX_VALUE
|
|
||||||
masking = false
|
|
||||||
}
|
|
||||||
routing {
|
|
||||||
webSocket(GLASSES_WS_PATH) {
|
|
||||||
hub.add(this)
|
|
||||||
try {
|
|
||||||
for (frame in incoming) {
|
|
||||||
if (frame is Frame.Text) {
|
|
||||||
val text = frame.readText()
|
|
||||||
hub.handle(this, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log("server", "WS-ошибка: ${e.message}")
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
hub.remove(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
|
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
|
||||||
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
|
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
|
||||||
|
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
|
||||||
|
* (SPP). Поведение по умолчанию (WiFi) не меняется.
|
||||||
*/
|
*/
|
||||||
class GlassesServer(
|
class GlassesServer(
|
||||||
private val port: Int = PhoneConfig.SERVER_PORT,
|
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||||
private val state: PhoneState = PhoneState(),
|
private val state: PhoneState = PhoneState(),
|
||||||
|
/** `false` (дефолт) — WiFi; `true` — Bluetooth SPP. */
|
||||||
|
useBluetooth: Boolean = false,
|
||||||
|
/** Context для runtime-проверки BT-пермишенов (нужен только в [BtServerTransport]). */
|
||||||
|
private val btContext: Context? = null,
|
||||||
) {
|
) {
|
||||||
val hub = GlassesHub(state)
|
/** Активный транспорт: [WifiServerTransport] (дефолт) или [BtServerTransport]. */
|
||||||
|
val transport: GlassesServerTransport =
|
||||||
|
if (useBluetooth) BtServerTransport(btContext) else WifiServerTransport(port)
|
||||||
|
|
||||||
|
val hub = GlassesHub(state, transport)
|
||||||
|
|
||||||
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
|
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
|
||||||
val sender: GlassesSender = object : GlassesSender {
|
val sender: GlassesSender = object : GlassesSender {
|
||||||
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
|
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var server: EmbeddedServer<*, *>? = null
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var acceptJob: Job? = null
|
||||||
|
|
||||||
|
/** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */
|
||||||
fun start() {
|
fun start() {
|
||||||
if (server != null) return
|
if (acceptJob != null) return
|
||||||
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
acceptJob = scope.launch {
|
||||||
glassesServerModule(hub)
|
transport.accept(
|
||||||
|
onMessage = { connId, json -> hub.handle(connId, json) },
|
||||||
|
onConnected = { connId -> hub.add(connId) },
|
||||||
|
onDisconnected = { connId -> hub.remove(connId) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
server!!.start(wait = false)
|
|
||||||
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
server?.stop(1_000, 3_000)
|
transport.close()
|
||||||
server = null
|
scope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
package pw.binom.viewmate.phone
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
/** Единый лог телефона: простой println (работает и в unit-тестах). */
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый лог телефона.
|
||||||
|
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
|
||||||
|
* (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
|
||||||
|
* чтобы не падать.
|
||||||
|
*/
|
||||||
fun log(tag: String, message: String) {
|
fun log(tag: String, message: String) {
|
||||||
println("[view-mate] $tag $message")
|
val full = "[view-mate] $tag $message"
|
||||||
|
runCatching {
|
||||||
|
Log.d("view-mate-$tag", full)
|
||||||
|
}.onFailure {
|
||||||
|
println(full)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package pw.binom.viewmate.phone
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
@@ -7,6 +9,7 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import pw.binom.viewmate.phone.stt.SttDebug
|
import pw.binom.viewmate.phone.stt.SttDebug
|
||||||
import pw.binom.viewmate.phone.agent.LlmDebug
|
import pw.binom.viewmate.phone.agent.LlmDebug
|
||||||
import pw.binom.viewmate.phone.ui.ViewMateApp
|
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||||
@@ -15,6 +18,7 @@ import java.io.File
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestBtPermissions()
|
||||||
setContent {
|
setContent {
|
||||||
ViewMateApp()
|
ViewMateApp()
|
||||||
}
|
}
|
||||||
@@ -26,6 +30,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-запрос Bluetooth-пермишенов (TASK-transport.md п.5): только при
|
||||||
|
* вкл. SPP ([PhoneConfig.USE_BLUETOOTH]). На 31+ — BLUETOOTH_CONNECT/SCAN,
|
||||||
|
* на ≤30 — BLUETOOTH/BLUETOOTH_ADMIN. Если уже выданы — молча пропускаем.
|
||||||
|
*/
|
||||||
|
private fun requestBtPermissions() {
|
||||||
|
if (!PhoneConfig.USE_BLUETOOTH) return
|
||||||
|
val needed = BtServerTransport.runtimePermissions()
|
||||||
|
val missing = needed.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
requestPermissions(missing.toTypedArray(), 1009)
|
||||||
|
log("bt", "запрошены Bluetooth-пермишены (SPP): ${missing.joinToString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
|
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
|
||||||
private fun runLlmDebug() {
|
private fun runLlmDebug() {
|
||||||
showOverlay("[llm] вызываю llm.binom.pw...")
|
showOverlay("[llm] вызываю llm.binom.pw...")
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import pw.binom.mercury.DiscoveredGlasses
|
||||||
|
import pw.binom.mercury.RayNeoMercury
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
|
||||||
|
*
|
||||||
|
* Минимум для полевого теста: скан → pair → connect → погасить/включить экран
|
||||||
|
* (deepSuspend). События либы логируются и частично проксируются в [status].
|
||||||
|
*
|
||||||
|
* Кнопки экрана — только проверка транспорта: если очки принимают команду,
|
||||||
|
* протокол жив, дальше можно вешать остальные команды (яркость, WiFi, магазин).
|
||||||
|
*/
|
||||||
|
object MercuryBridge {
|
||||||
|
|
||||||
|
private const val TAG = "MercuryBridge"
|
||||||
|
|
||||||
|
private var mercury: RayNeoMercury? = null
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
data class UiState(
|
||||||
|
val connected: Boolean = false,
|
||||||
|
val glassesName: String? = null,
|
||||||
|
val statusText: String = "не подключено",
|
||||||
|
val lastError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(UiState())
|
||||||
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var currentGlasses: DiscoveredGlasses? = null
|
||||||
|
|
||||||
|
/** Инициализация (вызывать из Application). Стартует GATT-сервер: очки сами нас найдут. */
|
||||||
|
fun init(context: Context) {
|
||||||
|
if (mercury != null) return
|
||||||
|
mercury = RayNeoMercury(context.applicationContext, scope)
|
||||||
|
scope.launch {
|
||||||
|
mercury!!.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is RayNeoMercury.Event.Connected -> {
|
||||||
|
_state.value = _state.value.copy(connected = true)
|
||||||
|
setStatus("подключено (BLE)")
|
||||||
|
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
|
||||||
|
// при гашении экрана очки рвут BLE-связь через ~3 секунды.
|
||||||
|
// Повторяем, пока очки не будут готовы (они переподключаются
|
||||||
|
// в первые секунды и первое сообщение могут потерять).
|
||||||
|
scope.launch {
|
||||||
|
for (i in 0 until 30) {
|
||||||
|
if (!_state.value.connected) break
|
||||||
|
val ok = mercury?.enableHeartbeat() ?: false
|
||||||
|
if (i % 3 == 0) log("hb", "enable_heartbeat #$i ok=$ok")
|
||||||
|
delay(3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is RayNeoMercury.Event.Disconnected -> {
|
||||||
|
_state.value = _state.value.copy(connected = false)
|
||||||
|
setStatus("отключено: ${event.cause}")
|
||||||
|
}
|
||||||
|
is RayNeoMercury.Event.JsonMessage -> {
|
||||||
|
log("json", "${event.uri} ${event.json.take(200)}")
|
||||||
|
// Очки шлют ble.request.* и ждут ble.response.* с тем же id.
|
||||||
|
// Без ответа они рвут соединение — отвечаем эхом cmd/payload.
|
||||||
|
val uri = event.uri
|
||||||
|
val id = event.id
|
||||||
|
if (uri != null && id != null && uri.startsWith("ble.request.") &&
|
||||||
|
uri != "ble.request.general.status" && uri != "ble.request.general.settings"
|
||||||
|
) {
|
||||||
|
val respUri = uri.replace("ble.request.", "ble.response.")
|
||||||
|
val payload = parsePayload(event.json)
|
||||||
|
scope.launch {
|
||||||
|
mercury?.send(pw.binom.mercury.model.PayloadMessage(respUri, id, payload))
|
||||||
|
log("resp", "answered $uri -> $respUri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is RayNeoMercury.Event.StreamPacket -> log("stream", "packet seq=${event.seq} type=${event.type} ${event.payload.size}B")
|
||||||
|
is RayNeoMercury.Event.AppInstallStatus -> setStatus("app: ${event.pkg} status=${event.status} err=${event.errCode}")
|
||||||
|
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
|
||||||
|
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
|
||||||
|
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
|
||||||
|
is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}")
|
||||||
|
is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val started = mercury!!.startServer()
|
||||||
|
log("server", if (started) "GATT-сервер + реклама запущены" else "GATT-сервер НЕ стартовал")
|
||||||
|
setStatus(if (started) "рекламирую сервис… жду очки" else "ошибка старта GATT-сервера")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прав на BLE нет — вернуть false; хост-приложение запрашивает само. */
|
||||||
|
fun hasBlePermissions(context: Context): Boolean {
|
||||||
|
val m = mercury ?: return false
|
||||||
|
return m.hasPermissions() &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключение: гарантирует, что GATT-сервер рекламируется, и ждёт, пока
|
||||||
|
* очки сами подключатся к нам (они действуют как GATT-клиент). Если очки
|
||||||
|
* всё же рекламируют — дополнительно пробуем скан-путь (клиент).
|
||||||
|
*/
|
||||||
|
suspend fun connectFirst(timeoutMillis: Long = 25_000): Boolean {
|
||||||
|
val m = mercury ?: return false
|
||||||
|
if (!m.isServerRunning && !m.startServer()) {
|
||||||
|
setError("не удалось запустить GATT-сервер")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setStatus("рекламирую сервис… жду очки")
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
if (m.isAnyConnected) {
|
||||||
|
_state.value = _state.value.copy(connected = true)
|
||||||
|
setStatus("подключено (BLE)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// На случай, если очки рекламируются сами — клиентский путь.
|
||||||
|
val glasses = m.scanOnce(3000)
|
||||||
|
if (glasses != null) {
|
||||||
|
setStatus("найдены: ${glasses.name ?: glasses.address}, подключаюсь…")
|
||||||
|
currentGlasses = glasses
|
||||||
|
if (m.connect(glasses, 8000)) {
|
||||||
|
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
|
||||||
|
setStatus("подключено (клиент)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError("очки не подключились за ${timeoutMillis / 1000} с")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
mercury?.disconnect()
|
||||||
|
_state.value = _state.value.copy(connected = false)
|
||||||
|
setStatus("отключено")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Погасить экран очков (deepSuspend=1). */
|
||||||
|
suspend fun screenOff(): Boolean {
|
||||||
|
val ok = mercury?.setDeepSuspend(true) ?: false
|
||||||
|
setStatus(if (ok) "команда «погасить экран» отправлена" else "ошибка отправки (нет связи?)")
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Включить экран очков (deepSuspend=0). */
|
||||||
|
suspend fun screenOn(): Boolean {
|
||||||
|
val ok = mercury?.setDeepSuspend(false) ?: false
|
||||||
|
setStatus(if (ok) "команда «включить экран» отправлена" else "ошибка отправки (нет связи?)")
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Автоблокировка экрана через N секунд; -1 = никогда. */
|
||||||
|
suspend fun autoLockTime(time: Int): Boolean {
|
||||||
|
val ok = mercury?.setAutoLockTime(time) ?: false
|
||||||
|
setStatus(if (ok) "auto_lock_time=$time отправлен" else "ошибка отправки (нет связи?)")
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setStatus(text: String) {
|
||||||
|
log("status", text)
|
||||||
|
_state.value = _state.value.copy(statusText = text, lastError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setError(text: String) {
|
||||||
|
Log.e(TAG, text)
|
||||||
|
_state.value = _state.value.copy(statusText = text, lastError = text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePayload(json: String): pw.binom.mercury.model.Payload? = try {
|
||||||
|
val obj = kotlinx.serialization.json.Json.parseToJsonElement(json).jsonObject
|
||||||
|
val el = obj["payload"] ?: return null
|
||||||
|
kotlinx.serialization.json.Json.decodeFromJsonElement(pw.binom.mercury.model.Payload.serializer(), el)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(tag: String, msg: String) {
|
||||||
|
Log.d(TAG, "[$tag] $msg")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import java.io.File
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -21,13 +23,31 @@ import pw.binom.viewmate.core.phone.PhoneActions
|
|||||||
import pw.binom.viewmate.core.phone.PhoneState
|
import pw.binom.viewmate.core.phone.PhoneState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantState
|
import pw.binom.viewmate.core.protocol.AssistantState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||||
import pw.binom.viewmate.core.protocol.ShowText
|
import pw.binom.viewmate.core.protocol.ShowText
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
import pw.binom.viewmate.core.protocol.SttDone
|
||||||
|
import pw.binom.viewmate.phone.agent.AgentToolkit
|
||||||
import pw.binom.viewmate.phone.agent.Assistant
|
import pw.binom.viewmate.phone.agent.Assistant
|
||||||
import pw.binom.viewmate.phone.agent.ChatDao
|
import pw.binom.viewmate.phone.agent.ChatDao
|
||||||
import pw.binom.viewmate.phone.agent.ChatDb
|
import pw.binom.viewmate.phone.agent.ChatDb
|
||||||
import pw.binom.viewmate.phone.agent.LlmClient
|
import pw.binom.viewmate.phone.agent.ChatRole
|
||||||
|
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
||||||
|
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
||||||
|
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
||||||
|
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||||
|
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||||
|
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||||
|
import pw.binom.viewmate.phone.agent.LlmPrefs
|
||||||
|
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
|
||||||
|
import pw.binom.viewmate.phone.agent.MediaToolExecutor
|
||||||
|
import pw.binom.viewmate.phone.agent.RemoteLlmClient
|
||||||
|
import pw.binom.viewmate.phone.agent.SkillRegistry
|
||||||
|
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||||
|
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||||
|
import pw.binom.viewmate.phone.stt.PhraseRecognizer
|
||||||
|
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
|
||||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||||
|
|
||||||
@@ -39,8 +59,13 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
val state: PhoneState = PhoneState()
|
val state: PhoneState = PhoneState()
|
||||||
|
|
||||||
/** WS-сервер для очков. */
|
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
|
||||||
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
val server: GlassesServer = GlassesServer(
|
||||||
|
port = PhoneConfig.SERVER_PORT,
|
||||||
|
state = state,
|
||||||
|
useBluetooth = PhoneConfig.USE_BLUETOOTH,
|
||||||
|
btContext = this,
|
||||||
|
)
|
||||||
|
|
||||||
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
||||||
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
||||||
@@ -85,16 +110,165 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
private var assistantLock = Any()
|
private var assistantLock = Any()
|
||||||
private var assistant: Assistant? = null
|
private var assistant: Assistant? = null
|
||||||
|
private var toolkit: AgentToolkit? = null
|
||||||
|
/** Модель, на которой создан текущий [assistant] — смена выбора пересоздаёт ассистента. */
|
||||||
|
private var assistantChoice: LlmModelChoice? = null
|
||||||
|
/** Активный локальный LLM-клиент (движок закрываем при смене выбора). */
|
||||||
|
private var localLlm: LocalLlmClient? = null
|
||||||
|
|
||||||
/** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */
|
/** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */
|
||||||
|
fun localLlmClient(): LocalLlmClient? = localLlm
|
||||||
|
|
||||||
|
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
|
||||||
|
// lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
|
||||||
|
// getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
|
||||||
|
// тот же паттерн, что у chatDao/downloadManager (иначе NPE при старте).
|
||||||
|
val llmPrefs: LlmPrefs by lazy { LlmPrefs(this) }
|
||||||
|
private val _llmChoice: MutableStateFlow<LlmModelChoice> by lazy { MutableStateFlow(llmPrefs.current()) }
|
||||||
|
val llmChoice: StateFlow<LlmModelChoice> by lazy { _llmChoice.asStateFlow() }
|
||||||
|
|
||||||
|
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */
|
||||||
|
private val _localLlmState: MutableStateFlow<LocalLlmModelState> by lazy { MutableStateFlow(initialLocalLlmState()) }
|
||||||
|
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
|
||||||
|
|
||||||
|
private var localLlmDownloadJob: Job? = null
|
||||||
|
private fun initialLocalLlmState(): LocalLlmModelState =
|
||||||
|
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
|
||||||
|
|
||||||
|
/** Файл локальной модели: getExternalFilesDir(null)/gemma-4-E2B-it.litertlm. */
|
||||||
|
fun localModelFile(): File = File(getExternalFilesDir(null) ?: filesDir, Gemma4E2B.FILE_NAME)
|
||||||
|
|
||||||
|
/** Сменить выбор модели: персист + пересоздание ассистента при следующей фразе. */
|
||||||
|
fun setLlmModelChoice(choice: LlmModelChoice) {
|
||||||
|
if (_llmChoice.value == choice) return
|
||||||
|
llmPrefs.set(choice)
|
||||||
|
_llmChoice.value = choice
|
||||||
|
synchronized(assistantLock) { assistant = null }
|
||||||
|
log("llm", "выбрана модель: ${choice.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
|
||||||
|
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
|
||||||
|
* Повторный вызов во время скачивания — no-op.
|
||||||
|
*/
|
||||||
|
fun downloadLocalLlmModel() {
|
||||||
|
val current = localLlmDownloadJob
|
||||||
|
if (current != null && current.isActive) return
|
||||||
|
localLlmDownloadJob = scope.launch {
|
||||||
|
val target = localModelFile()
|
||||||
|
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||||
|
try {
|
||||||
|
GemmaModelDownloader(target).download { percent ->
|
||||||
|
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||||
|
}
|
||||||
|
_localLlmState.value = LocalLlmModelState.Ready
|
||||||
|
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
|
||||||
|
log("llm", "не удалось скачать модель: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Остановить скачивание локальной модели (частичный файл удаляется). */
|
||||||
|
fun cancelLocalLlmDownload() {
|
||||||
|
localLlmDownloadJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу.
|
||||||
|
* LLM-клиент берётся по текущему выбору [llmChoice]: серверный или локальный
|
||||||
|
* LiteRT-LM; при смене выбора ассистент создаётся заново.
|
||||||
|
*/
|
||||||
private fun ensureAssistant(): Assistant? {
|
private fun ensureAssistant(): Assistant? {
|
||||||
synchronized(assistantLock) {
|
synchronized(assistantLock) {
|
||||||
assistant?.let { return it }
|
val choice = _llmChoice.value
|
||||||
if (BuildConfig.LLM_API_KEY.isBlank()) return null
|
if (assistant != null) {
|
||||||
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
if (assistantChoice == choice) return assistant
|
||||||
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) })
|
assistant = null
|
||||||
.also { assistant = it }
|
|
||||||
}
|
}
|
||||||
|
if (choice == LlmModelChoice.REMOTE && BuildConfig.LLM_API_KEY.isBlank()) {
|
||||||
|
assistantChoice = choice
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
runCatching { localLlm?.close() }
|
||||||
|
localLlm = null
|
||||||
|
val llm = when (choice) {
|
||||||
|
LlmModelChoice.REMOTE -> RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||||
|
LlmModelChoice.LOCAL -> {
|
||||||
|
// cacheDir — подкаталог cache/litertlm: в 11:46 именно с ним
|
||||||
|
// (без спекуляции) движок инициализировался за 12с; с корнем
|
||||||
|
// cache/ initialize() виснет. GPU-делегату нужен СВОЙ каталог.
|
||||||
|
val cacheDir = File(cacheDir, "litertlm")
|
||||||
|
LocalLlmClient(modelFile = localModelFile(), cacheDir = cacheDir).also { localLlm = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val toolsets = ToolsetRegistry()
|
||||||
|
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
|
||||||
|
val tk = toolkit ?: AgentToolkit(
|
||||||
|
skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()),
|
||||||
|
toolsets = toolsets,
|
||||||
|
showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } },
|
||||||
|
).also { toolkit = it }
|
||||||
|
return Assistant(
|
||||||
|
chatDao = chatDao,
|
||||||
|
chat = { history, system -> llm.chat(history, system) },
|
||||||
|
toolkit = tk,
|
||||||
|
// Локальной модели нужно время: инициализация движка ~17с на
|
||||||
|
// CPU + генерация. Серверная укладывается в 30с, локальной
|
||||||
|
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
|
||||||
|
timeoutMs = if (choice == LlmModelChoice.LOCAL) 300_000L else 30_000L,
|
||||||
|
).also {
|
||||||
|
assistant = it
|
||||||
|
assistantChoice = choice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тулсет media: тулы работают через [JellyfinMediaSource] (каталог) и уровня-приложения [agentPlayMedia] («включить на очках»). */
|
||||||
|
private fun mediaToolExecutor(): MediaToolExecutor = MediaToolExecutor(
|
||||||
|
media = JellyfinMediaSource(jellyfin) { userId.value },
|
||||||
|
player = { id, seekMs -> agentPlayMedia(id, seekMs) },
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тул ассистента media_play: проверки (очки на связи, видео скачано на очки,
|
||||||
|
* звук скачан на телефон) + запуск через [PhoneActions.watch] (механизм DetailsScreen)
|
||||||
|
* + звук на телефоне выбранной дорожкой с позиции [seekMs]. Возвращает текст для модели.
|
||||||
|
*/
|
||||||
|
private suspend fun agentPlayMedia(id: String, seekMs: Long): String {
|
||||||
|
val hub = server.hub
|
||||||
|
if (hub.connected.value == 0) return "Очки не подключены."
|
||||||
|
val mediaDir = hub.mediaDir.value
|
||||||
|
if (mediaDir.isNullOrBlank()) return "Очки ещё не опубликовали медиакаталог — попробуйте позже."
|
||||||
|
if (id !in hub.downloadedItemIds.value) return "Видео не скачано на очки. Скачайте его вручную через телефон."
|
||||||
|
if (!downloadManager.audioReady(id)) return "Звук не готов: сначала скачайте аудио на телефон."
|
||||||
|
val acts = actions ?: return "Телефон ещё не готов."
|
||||||
|
val audioIndex = state.currentAudioIndex
|
||||||
|
log("agent", "play: $id, звук #$audioIndex, seek=${seekMs} мс")
|
||||||
|
|
||||||
|
val result = acts.watch(
|
||||||
|
itemId = id,
|
||||||
|
audioIndex = audioIndex,
|
||||||
|
localVideoPath = "file://$mediaDir/$id/video.mkv",
|
||||||
|
phoneAudioReady = true,
|
||||||
|
startPositionMs = seekMs,
|
||||||
|
)
|
||||||
|
if (!result.contains("включил")) return result
|
||||||
|
|
||||||
|
// Звук на телефоне (с позиции, как DetailsScreen): локальный ogg или S3-стрим.
|
||||||
|
if (audioSync.isActive) audioSync.stop()
|
||||||
|
val audio = resolveAudioSourceDetailed(
|
||||||
|
mirrorFiles = mirror.mirrorByItem(id)?.files,
|
||||||
|
audioIndex = audioIndex,
|
||||||
|
localAudioFile = { downloadManager.audioFile(id, it) },
|
||||||
|
)
|
||||||
|
if (audio.source == null) return "Видео включено, но источники звука отсутствуют: ${audio.reason}"
|
||||||
|
audioSync.play(audio.source, seekMs.coerceAtLeast(0L))
|
||||||
|
return "Включено: «${state.currentItem?.Name ?: id}»"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,20 +277,73 @@ class PhoneApp : Application() {
|
|||||||
*/
|
*/
|
||||||
private suspend fun assistantLoop() {
|
private suspend fun assistantLoop() {
|
||||||
for (phrase in assistantChannel) {
|
for (phrase in assistantChannel) {
|
||||||
|
log("assistant", "получена фраза: $phrase")
|
||||||
val assistant = ensureAssistant()
|
val assistant = ensureAssistant()
|
||||||
if (assistant == null) {
|
if (assistant == null) {
|
||||||
log("assistant", "LLM-ключ не задан — фраза пропущена")
|
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||||
val answer = runCatching { assistant.process(phrase) }
|
val answer = runCatching {
|
||||||
|
val active = _activeSessionId.value
|
||||||
|
if (active != null) {
|
||||||
|
assistant.processInSession(active, phrase)
|
||||||
|
} else {
|
||||||
|
assistant.process(phrase)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||||
server.hub.broadcast(ShowText(answer))
|
server.hub.broadcast(ShowText(answer))
|
||||||
|
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||||
|
server.hub.broadcast(chatHistoryMsg())
|
||||||
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
|
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История активной (или последней) сессии для очков: только роли user/assistant,
|
||||||
|
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
||||||
|
*/
|
||||||
|
private fun chatHistoryMsg(): ChatHistoryMsg {
|
||||||
|
val id = _activeSessionId.value ?: chatDao.latestSessionId()
|
||||||
|
if (id == null) return ChatHistoryMsg(sessionId = null, messages = emptyList())
|
||||||
|
val items = chatDao.messagesForSession(id)
|
||||||
|
.filter { it.role == ChatRole.USER || it.role == ChatRole.ASSISTANT }
|
||||||
|
.map { ChatHistoryItem(role = it.role.wire, content = it.content) }
|
||||||
|
return ChatHistoryMsg(sessionId = id.toString(), messages = items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправка текста в ассистента из чата на телефоне (не с очков).
|
||||||
|
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
|
||||||
|
*/
|
||||||
|
fun sendToAssistant(text: String) {
|
||||||
|
scope.launch { assistantChannel.send(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Активная сессия чата (для UI телефона). null — «последняя» (дефолт). */
|
||||||
|
private val _activeSessionId = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSessionId: StateFlow<Long?> = _activeSessionId
|
||||||
|
|
||||||
|
/** Создать новую сессию и сделать её активной. */
|
||||||
|
fun newChatSession(): Long {
|
||||||
|
val id = chatDao.createSession()
|
||||||
|
_activeSessionId.value = id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переключить активную сессию. null — вернуться к «последней». */
|
||||||
|
fun selectChatSession(id: Long?) {
|
||||||
|
_activeSessionId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сбросить (очистить сообщения) активной или последней сессии. */
|
||||||
|
fun resetChatSession() {
|
||||||
|
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: return
|
||||||
|
chatDao.clearSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
||||||
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
||||||
@@ -126,35 +353,60 @@ class PhoneApp : Application() {
|
|||||||
synchronized(sttLock) {
|
synchronized(sttLock) {
|
||||||
sttStreamer?.let { return it }
|
sttStreamer?.let { return it }
|
||||||
val modelsDir = File(filesDir, "models")
|
val modelsDir = File(filesDir, "models")
|
||||||
val vadModel = File(modelsDir, "silero_vad.onnx")
|
val 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")
|
||||||
|
var stt: PhraseRecognizer
|
||||||
|
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 encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||||
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
||||||
val tokens = File(modelsDir, "small-tokens.txt")
|
val tokens = File(modelsDir, "small-tokens.txt")
|
||||||
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
||||||
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val whisper = WhisperStt(
|
stt = WhisperStt(
|
||||||
encoderPath = encoder.absolutePath,
|
encoderPath = encoder.absolutePath,
|
||||||
decoderPath = decoder.absolutePath,
|
decoderPath = decoder.absolutePath,
|
||||||
tokensPath = tokens.absolutePath,
|
tokensPath = tokens.absolutePath,
|
||||||
numThreads = 4,
|
numThreads = 4,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
||||||
|
File(filesDir, "stt_dump_phone.raw")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
|
||||||
val s = SttStreamer(
|
val s = SttStreamer(
|
||||||
stt = whisper,
|
stt = stt,
|
||||||
vadModelPath = vadModel.absolutePath,
|
|
||||||
onPhrase = { phrase, full ->
|
|
||||||
log("stt", "фраза: $phrase")
|
|
||||||
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
|
||||||
scope.launch { assistantChannel.send(phrase) }
|
|
||||||
},
|
|
||||||
onSilence30s = {
|
onSilence30s = {
|
||||||
log("stt", "тишина 30 с — автo-отмена")
|
log("stt", "тишина 30 с — автo-отмена")
|
||||||
scope.launch { server.hub.broadcast(SttCancel()) }
|
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||||
},
|
},
|
||||||
|
onAutoFinished = { full ->
|
||||||
|
log("stt", "тишина 30 с — ВЕСЬ ТЕКСТ: $full")
|
||||||
|
// Тот же путь, что и результат по клику: в очки SttDone + в LLM.
|
||||||
|
scope.launch { server.hub.broadcast(SttDone(full)) }
|
||||||
|
scope.launch { assistantChannel.send(full.trim()) }
|
||||||
|
},
|
||||||
|
dumpPcm = dumpPcm,
|
||||||
)
|
)
|
||||||
sttStreamer = s
|
sttStreamer = s
|
||||||
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||||
|
log("stt", "SttStreamer готов ($STT_BACKEND)")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,10 +441,17 @@ class PhoneApp : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
|
PlaybackService.start(this)
|
||||||
|
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
|
||||||
|
|
||||||
|
MercuryBridge.init(this)
|
||||||
|
|
||||||
server.start()
|
server.start()
|
||||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||||
|
DebugHttpServer.start(this)
|
||||||
nsd.publish()
|
nsd.publish()
|
||||||
server.hub.sttFactory = { ensureStt() }
|
server.hub.sttFactory = { ensureStt() }
|
||||||
|
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
|
||||||
scope.launch { assistantLoop() }
|
scope.launch { assistantLoop() }
|
||||||
|
|
||||||
val lastPositionAt = AtomicLong(0)
|
val lastPositionAt = AtomicLong(0)
|
||||||
@@ -264,6 +523,7 @@ class PhoneApp : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
|
PlaybackService.stop(this)
|
||||||
nsd.unpublish()
|
nsd.unpublish()
|
||||||
sttStreamer?.close()
|
sttStreamer?.close()
|
||||||
server.stop()
|
server.stop()
|
||||||
@@ -273,5 +533,11 @@ class PhoneApp : Application() {
|
|||||||
companion object {
|
companion object {
|
||||||
lateinit var instance: PhoneApp
|
lateinit var instance: PhoneApp
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
|
||||||
|
private const val STT_BACKEND = "qwen3_asr"
|
||||||
|
|
||||||
|
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||||
|
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ object PhoneConfig {
|
|||||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||||
const val SERVER_PORT = 8080
|
const val SERVER_PORT = 8080
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
|
||||||
|
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
|
||||||
|
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
|
||||||
|
*/
|
||||||
|
const val USE_BLUETOOTH = true
|
||||||
|
|
||||||
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
||||||
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service типа mediaPlayback: держит процесс телефона живым (WS-сервер +
|
||||||
|
* аудио-плеер) при выключенном экране. Без него HONOR замораживает/убивает процесс
|
||||||
|
* в фоне → очки теряют связь (видео паузится) и звук встаёт. Является спутником
|
||||||
|
* WS-сервера: стартует в PhoneApp.onCreate, останавливается в onTerminate.
|
||||||
|
*/
|
||||||
|
class PlaybackService : Service() {
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "viewmate_playback"
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
context, Intent(context, PlaybackService::class.java)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, PlaybackService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "ViewMate playback",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) }
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle("ViewMate")
|
||||||
|
.setContentText("Звук на телефоне при просмотре на очках")
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
// держим CPU и WiFi живыми при выключенном экране
|
||||||
|
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
|
||||||
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
|
||||||
|
?.apply { acquire() }
|
||||||
|
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
|
||||||
|
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
|
||||||
|
?.apply { acquire() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||||
|
START_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
wifiLock?.takeIf { it.isHeld }?.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.cio.CIO
|
||||||
|
import io.ktor.server.engine.EmbeddedServer
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import io.ktor.websocket.send
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WiFi-транспорт, сторона телефона («сервер») — TASK-transport.md п.2:
|
||||||
|
* [GlassesServerTransport] на Ktor-CIO-сервере (WebSocket [GLASSES_WS_PATH]).
|
||||||
|
* Сессии — [connId] (UUID-строки, не WS-объекты): мост между WS-сессией и
|
||||||
|
* connId живёт здесь, [GlassesHub] знает только строки.
|
||||||
|
*/
|
||||||
|
class WifiServerTransport(
|
||||||
|
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||||
|
) : GlassesServerTransport {
|
||||||
|
|
||||||
|
override val name = "wifi"
|
||||||
|
|
||||||
|
private val sessionByConn = ConcurrentHashMap<String, DefaultWebSocketServerSession>()
|
||||||
|
@Volatile
|
||||||
|
private var server: EmbeddedServer<*, *>? = null
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
/** Бесконечно принимать очки: старт Ktor-сервера + холд (пока [close]). */
|
||||||
|
override suspend fun accept(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
||||||
|
glassesWsModule(onMessage, onConnected, onDisconnected)
|
||||||
|
}
|
||||||
|
server!!.start(wait = false)
|
||||||
|
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
|
||||||
|
while (!closed) {
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ktor-модуль: WS-роут очков; сессия адресуется connId (UUID), события — колбэки [accept]. */
|
||||||
|
private fun Application.glassesWsModule(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
install(WebSockets) {
|
||||||
|
pingPeriodMillis = 5_000
|
||||||
|
timeoutMillis = 5_000
|
||||||
|
maxFrameSize = Long.MAX_VALUE
|
||||||
|
masking = false
|
||||||
|
}
|
||||||
|
routing {
|
||||||
|
webSocket(GLASSES_WS_PATH) {
|
||||||
|
val connId = UUID.randomUUID().toString()
|
||||||
|
sessionByConn[connId] = this
|
||||||
|
onConnected(connId)
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
onMessage(connId, frame.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("server", "WS-ошибка: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
sessionByConn.remove(connId)
|
||||||
|
onDisconnected(connId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(connId: String, json: String) {
|
||||||
|
sessionByConn[connId]?.send(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
runCatching { server?.stop(1_000, 3_000) }
|
||||||
|
server = null
|
||||||
|
sessionByConn.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import pw.binom.viewmate.core.log
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Механика агента: ядро тулов + тулсеты + скиллы + сборка системного
|
||||||
|
* промпта. Чистая JVM-логика (без Android), юнит-тестируется.
|
||||||
|
*
|
||||||
|
* Три состояния промпта (пересобирается на каждый запрос):
|
||||||
|
* 1. Набор не активен: ядро + каталог тулсетов + индекс скиллов.
|
||||||
|
* 2. Активация: enable пересобирает промпт, сам вызов в историю НЕ пишется
|
||||||
|
* («системная тула-невидимка»).
|
||||||
|
* 3. Активен: ядро + описания тул набора + «Активные тулсеты: имя»;
|
||||||
|
* скилл, связанный с набором (имя скилла = имя тулсета), УБРАН из промпта.
|
||||||
|
*
|
||||||
|
* Бесшовная подмена: тул набора при НЕактивном наборе не выполняется вслепую —
|
||||||
|
* подменяется на enable_toolset|набор и модели явно сообщается, что вызов ещё
|
||||||
|
* НЕ выполнялся (иначе она выдумает результат). Если аргументы совпадают со
|
||||||
|
* схемой и исполнитель есть — вызов выполняется сразу, без второго круга.
|
||||||
|
*/
|
||||||
|
class AgentToolkit(
|
||||||
|
private val skills: SkillRegistry,
|
||||||
|
private val toolsets: ToolsetRegistry,
|
||||||
|
private val showText: ((String) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TOOL_TIME = "get_current_time"
|
||||||
|
const val TOOL_SHOW_TEXT = "show_text"
|
||||||
|
const val TOOL_READ_SKILL = "read_skill"
|
||||||
|
const val TOOL_ENABLE = "enable_toolset"
|
||||||
|
const val TOOL_DISABLE = "disable_toolset"
|
||||||
|
|
||||||
|
/** Ядро: 5 тул, всегда в промпте, компактно. */
|
||||||
|
val CORE_TOOL_NAMES: List<String> =
|
||||||
|
listOf(TOOL_TIME, TOOL_SHOW_TEXT, TOOL_READ_SKILL, TOOL_ENABLE, TOOL_DISABLE)
|
||||||
|
|
||||||
|
private val CORE_LINES = listOf(
|
||||||
|
"get_current_time — текущие дата, время, часовой пояс (ISO 8601).",
|
||||||
|
"show_text|text — показать текст на очках.",
|
||||||
|
"read_skill|имя — прочитать полное содержимое скилла по имени.",
|
||||||
|
"enable_toolset|имя — активировать тулсет (живёт 10 мин от последнего вызова его тул).",
|
||||||
|
"disable_toolset|имя — деактивировать тулсет.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Одноразовые напоминания по сессиям (невидимые тулы: «уже активен», «набора нет»). */
|
||||||
|
private val pendingNotes = ConcurrentHashMap<Long, String>()
|
||||||
|
|
||||||
|
// ---------- сборка промпта ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
|
||||||
|
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
|
||||||
|
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
|
||||||
|
*/
|
||||||
|
fun systemPrompt(sessionId: Long): String {
|
||||||
|
val note = pendingNotes.remove(sessionId)
|
||||||
|
val activeSets = toolsets.activeNames(sessionId)
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
|
||||||
|
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
|
||||||
|
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
|
||||||
|
.append("После получения результата тула ответь пользователю текстом.")
|
||||||
|
sb.append("\n\nЯдро:")
|
||||||
|
CORE_LINES.forEach { sb.append('\n').append(it) }
|
||||||
|
val catalog = toolsets.catalogLines()
|
||||||
|
if (catalog.isNotEmpty()) {
|
||||||
|
sb.append("\n\nКаталог тулсетов (активация — enable_toolset|имя):")
|
||||||
|
catalog.forEach { sb.append('\n').append(it) }
|
||||||
|
}
|
||||||
|
if (activeSets.isNotEmpty()) {
|
||||||
|
sb.append("\n\nАктивные тулсеты: ").append(activeSets.joinToString(", "))
|
||||||
|
activeSets.forEach { name ->
|
||||||
|
toolsets.toolset(name)?.toolLines()?.forEach { sb.append('\n').append(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val skillLines = skills.index(excluded = activeSets.toSet())
|
||||||
|
if (skillLines.isNotEmpty()) {
|
||||||
|
sb.append("\n\nСкиллы (read_skill|имя):")
|
||||||
|
skillLines.forEach { sb.append('\n').append(it) }
|
||||||
|
}
|
||||||
|
if (note != null) sb.append("\n\nВажно: ").append(note)
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- парсинг аргументов ----------
|
||||||
|
|
||||||
|
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
|
||||||
|
fun schemaFor(tool: String): List<String> = when (tool) {
|
||||||
|
TOOL_TIME -> emptyList()
|
||||||
|
TOOL_SHOW_TEXT -> listOf("text")
|
||||||
|
TOOL_READ_SKILL -> listOf("name")
|
||||||
|
TOOL_ENABLE, TOOL_DISABLE -> listOf("name")
|
||||||
|
else -> toolsets.signatureOf(tool)?.args ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||||
|
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
||||||
|
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
||||||
|
*/
|
||||||
|
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
|
||||||
|
val s = raw?.trim()
|
||||||
|
if (s.isNullOrEmpty()) return emptyMap()
|
||||||
|
val required = expected.filterNot { it.endsWith("?") }.distinct()
|
||||||
|
val out = LinkedHashMap<String, String>()
|
||||||
|
var bareIndex = 0
|
||||||
|
for (field in s.split(ALTER)) {
|
||||||
|
val f = field.trim()
|
||||||
|
if (f.isEmpty()) continue
|
||||||
|
val eq = f.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
val key = f.substring(0, eq).trim()
|
||||||
|
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
|
||||||
|
} else if (bareIndex < required.size) {
|
||||||
|
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
|
||||||
|
out[required[bareIndex++]] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ALTER = Regex("[,&]")
|
||||||
|
|
||||||
|
// ---------- диспач вызовов ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат диспача:
|
||||||
|
* [Run] — результат пишется в историю (TOOL-сообщение);
|
||||||
|
* [Silent] — невидимая системная тула (enable/disable_toolset): в историю
|
||||||
|
* НЕ пишется, просто пересобирается промпт на следующем запросе.
|
||||||
|
*/
|
||||||
|
sealed interface Dispatch {
|
||||||
|
class Run(val result: String) : Dispatch
|
||||||
|
object Silent : Dispatch
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диспач вызова тула: ядро + префиксная маршрутизация в тулсеты.
|
||||||
|
* Все тулы идемпотентные и прощающие; аргументы валидируются по реестру.
|
||||||
|
*/
|
||||||
|
fun dispatch(sessionId: Long, tool: String, args: Map<String, String>): Dispatch {
|
||||||
|
when (tool) {
|
||||||
|
TOOL_TIME -> return Dispatch.Run(TimeTool.invoke("{}"))
|
||||||
|
|
||||||
|
TOOL_SHOW_TEXT -> {
|
||||||
|
val text = args["text"]
|
||||||
|
if (text.isNullOrBlank()) {
|
||||||
|
return Dispatch.Run(
|
||||||
|
"show_text: аргументы неверны, не хватает \"text\". Схема: show_text|text=<текст>."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showText == null) {
|
||||||
|
return Dispatch.Run("Текст \"$text\" не показан: отображение на очки не подключено.")
|
||||||
|
}
|
||||||
|
showText(text)
|
||||||
|
log("toolset", "show_text (сессия $sessionId): $text")
|
||||||
|
return Dispatch.Run("Показано на очках: $text")
|
||||||
|
}
|
||||||
|
|
||||||
|
TOOL_READ_SKILL -> {
|
||||||
|
val name = args["name"]
|
||||||
|
if (name.isNullOrBlank()) {
|
||||||
|
return Dispatch.Run("read_skill: аргументы неверны, не хватает \"name\". Схема: read_skill|имя=<скилл>.")
|
||||||
|
}
|
||||||
|
val content = skills.readFull(name)
|
||||||
|
?: return Dispatch.Run("Скилл \"$name\" не найден. " + skillsAvailable())
|
||||||
|
log("toolset", "read_skill (сессия $sessionId): $name")
|
||||||
|
return Dispatch.Run(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
TOOL_ENABLE -> {
|
||||||
|
val name = args["name"]
|
||||||
|
if (name.isNullOrBlank()) {
|
||||||
|
return Dispatch.Run("enable_toolset: аргументы неверны, не хватает \"name\". Схема: enable_toolset|имя=<тулсет>.")
|
||||||
|
}
|
||||||
|
when (val r = toolsets.enable(sessionId, name)) {
|
||||||
|
is EnableResult.Activated ->
|
||||||
|
setNote(sessionId, if (r.alreadyActive) "Тулсет \"$name\" уже активен, таймаут продлён." else null)
|
||||||
|
is EnableResult.Unknown -> setNote(sessionId, r.message)
|
||||||
|
}
|
||||||
|
log("toolset", "enable_toolset (сессия $sessionId): $name")
|
||||||
|
return Dispatch.Silent
|
||||||
|
}
|
||||||
|
|
||||||
|
TOOL_DISABLE -> {
|
||||||
|
val name = args["name"]
|
||||||
|
if (name.isNullOrBlank()) {
|
||||||
|
return Dispatch.Run("disable_toolset: аргументы неверны, не хватает \"name\". Схема: disable_toolset|имя=<тулсет>.")
|
||||||
|
}
|
||||||
|
when (val r = toolsets.disable(sessionId, name)) {
|
||||||
|
is DisableResult.Disabled -> setNote(sessionId, null)
|
||||||
|
is DisableResult.NotActive -> setNote(sessionId, r.message)
|
||||||
|
}
|
||||||
|
log("toolset", "disable_toolset (сессия $sessionId): $name")
|
||||||
|
return Dispatch.Silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Префиксная маршрутизация: media_* → набор media.
|
||||||
|
val ts = toolsets.findByPrefix(tool)
|
||||||
|
?: return Dispatch.Run("Тул \"$tool\" не найден. " + available())
|
||||||
|
val sig = ts.signatureOf(tool)
|
||||||
|
?: return Dispatch.Run(
|
||||||
|
"Тул \"$tool\" в тулсете \"${ts.name}\" нет. Тулы тулсета: " +
|
||||||
|
ts.toolLines().joinToString("; ") + "."
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!toolsets.isActive(sessionId, ts.name)) {
|
||||||
|
// Бесшовная подмена: набор неактивен — активируем его (как будто
|
||||||
|
// вызван enable_toolset|набор) и явно говорим, что вызов ещё не выполнен.
|
||||||
|
toolsets.enable(sessionId, ts.name)
|
||||||
|
log("toolset", "подмена $tool → enable_toolset|${ts.name} (сессия $sessionId)")
|
||||||
|
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
|
||||||
|
val executor = toolsets.executor(ts.name)
|
||||||
|
if (missing.isEmpty() && executor != null) {
|
||||||
|
val result = executor.execute(ts.name, tool, args)
|
||||||
|
toolsets.touch(sessionId, ts.name)
|
||||||
|
return Dispatch.Run(result)
|
||||||
|
}
|
||||||
|
return Dispatch.Run(substitutionMessage(ts, tool, sig))
|
||||||
|
}
|
||||||
|
|
||||||
|
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
val schema = buildString {
|
||||||
|
append(tool)
|
||||||
|
if (sig.args.isNotEmpty()) append('|').append(sig.args.joinToString(","))
|
||||||
|
}
|
||||||
|
return Dispatch.Run(
|
||||||
|
"$tool: аргументы неверны, не хватает обязательных: ${missing.joinToString(", ")}. Схема: $schema."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val executor = toolsets.executor(ts.name)
|
||||||
|
?: return Dispatch.Run("Тулсет \"${ts.name}\" активен, но его тулы ещё не реализованы.")
|
||||||
|
val result = executor.execute(ts.name, tool, args)
|
||||||
|
toolsets.touch(sessionId, ts.name)
|
||||||
|
log("toolset", "$tool (сессия $sessionId) → $result")
|
||||||
|
return Dispatch.Run(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сообщение подмены: набор активирован + реестр + явная пометка «вызов ещё не выполнен». */
|
||||||
|
private fun substitutionMessage(ts: Toolset, tool: String, sig: ToolSignature): String {
|
||||||
|
val registry = ts.toolLines().joinToString("; ")
|
||||||
|
val usage = when {
|
||||||
|
sig.args.isEmpty() -> "Для выполнения вызови $tool."
|
||||||
|
else -> "Для выполнения вызови $tool с аргументами: ${sig.argNames().joinToString(", ")}."
|
||||||
|
}
|
||||||
|
return "Тулсет ${ts.name} активирован. Реестр: $registry. " +
|
||||||
|
"Вызов $tool ещё НЕ выполнялся. $usage"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setNote(sessionId: Long, note: String?) {
|
||||||
|
if (note == null) pendingNotes.remove(sessionId) else pendingNotes[sessionId] = note
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skillsAvailable(): String =
|
||||||
|
"Доступные скиллы: " + skills.names().joinToString(", ").ifBlank { "нет скиллов" }
|
||||||
|
|
||||||
|
private fun available(): String {
|
||||||
|
val sets = toolsets.names()
|
||||||
|
return "Доступны: " + CORE_TOOL_NAMES.joinToString(", ") +
|
||||||
|
if (sets.isEmpty()) ". Тулсетов нет." else ". Тулсеты: " + sets.joinToString(", ") + "."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тул ассистента: локальная функция, которую агент (LLM) может вызвать по
|
||||||
|
* имени. Результат возвращается строкой и подставляется в историю как
|
||||||
|
* tool-сообщение (роль [ChatRole.TOOL]).
|
||||||
|
*
|
||||||
|
* Добавление нового тула: реализовать [AgentTool], добавить в список
|
||||||
|
* [AgentTools.ALL]. Агент узнаёт о тулах из системного промпта (описание
|
||||||
|
* [describe]) — никакой магии.
|
||||||
|
*/
|
||||||
|
interface AgentTool {
|
||||||
|
/** Имя тула (как агент его называет, например `get_current_time`). */
|
||||||
|
val name: String
|
||||||
|
|
||||||
|
/** Короткое описание для системного промпта (что делает, что возвращает). */
|
||||||
|
val description: String
|
||||||
|
|
||||||
|
/** Вызвать тул. [arguments] — JSON-строка (пустая, если аргументов нет). */
|
||||||
|
fun invoke(arguments: String): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реестр тулов ассистента. */
|
||||||
|
object AgentTools {
|
||||||
|
val ALL: List<AgentTool> = listOf(TimeTool)
|
||||||
|
|
||||||
|
/** Описания для системного промпта: «Тул: name — description». */
|
||||||
|
fun describeAll(): String = ALL.joinToString("\n") { "Тул: ${it.name} — ${it.description}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тул времени: возвращает текущие дату, время и часовой пояс в ISO 8601
|
||||||
|
* (например `2026-08-22T03:14:05+03:00[Europe/Moscow]`). Локально на
|
||||||
|
* телефоне — LLM не нужна, время точное.
|
||||||
|
*/
|
||||||
|
object TimeTool : AgentTool {
|
||||||
|
override val name = "get_current_time"
|
||||||
|
override val description =
|
||||||
|
"Текущие дата и время с часовым поясом в формате ISO 8601. Аргументы не нужны (пустой JSON {})."
|
||||||
|
|
||||||
|
private val formatter: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
|
||||||
|
|
||||||
|
override fun invoke(arguments: String): String {
|
||||||
|
val now = Instant.now()
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
return formatter.format(now.atZone(zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,31 +7,153 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
|
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
|
||||||
* история накапливается в сессии. Синхронный вызов из одной корутины
|
* история накапливается в сессии. Синхронный вызов из одной корутины
|
||||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||||
|
*
|
||||||
|
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
||||||
|
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
|
||||||
|
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
|
||||||
|
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
||||||
|
* затем делает повторный вызов LLM. Невидимые системные тулы
|
||||||
|
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
||||||
|
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
|
||||||
|
* возвращается пользователю как финальный ответ.
|
||||||
*/
|
*/
|
||||||
class Assistant(
|
class Assistant(
|
||||||
private val chatDao: ChatDao,
|
private val chatDao: ChatDao,
|
||||||
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
||||||
|
private val toolkit: AgentToolkit? = null,
|
||||||
|
private val tools: List<AgentTool> = AgentTools.ALL,
|
||||||
private val timeoutMs: Long = 30_000,
|
private val timeoutMs: Long = 30_000,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
|
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
|
||||||
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
|
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
|
||||||
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
|
||||||
|
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
||||||
*/
|
*/
|
||||||
suspend fun process(text: String): String {
|
suspend fun process(text: String): String {
|
||||||
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент")
|
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession(randomTitle())
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
||||||
|
return processInSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обработать фразу в КОНКРЕТНОЙ сессии [sessionId] (переключение сессий). */
|
||||||
|
suspend fun processInSession(sessionId: Long, text: String): String {
|
||||||
|
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
||||||
|
// Сессия безымянная (создана кнопкой «новая сессия») — имя из первого сообщения.
|
||||||
|
val s = chatDao.getSession(sessionId)
|
||||||
|
if (s != null && s.title.isBlank()) {
|
||||||
|
val t = text.trim().take(30)
|
||||||
|
chatDao.renameSession(sessionId, if (t.isEmpty()) "беседка" else t)
|
||||||
|
}
|
||||||
|
return processInSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun processInSession(sessionId: Long): String {
|
||||||
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
||||||
val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) }
|
var answer = callLlm(history, sessionId)
|
||||||
|
var lastToolResult: String? = null
|
||||||
|
var visibleCalls = 0
|
||||||
|
var llmCalls = 1
|
||||||
|
// Цикл тулов: до [MAX_TOOL_CALLS] видимых вызовов подряд (защита от
|
||||||
|
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
|
||||||
|
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
|
||||||
|
while (true) {
|
||||||
|
val parsed = parseToolCall(answer)
|
||||||
|
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
|
||||||
|
val toolName = parsed.name
|
||||||
|
if (toolkit == null) {
|
||||||
|
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
|
||||||
|
val tool = tools.firstOrNull { it.name == toolName }
|
||||||
|
if (tool == null) {
|
||||||
|
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||||
|
return "Тул $toolName не найден"
|
||||||
|
}
|
||||||
|
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
|
||||||
|
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
|
||||||
|
logTool(sessionId, toolName, lastToolResult)
|
||||||
|
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||||
|
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||||
|
llmCalls++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) {
|
||||||
|
is AgentToolkit.Dispatch.Run -> {
|
||||||
|
lastToolResult = result.result
|
||||||
|
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
|
||||||
|
logTool(sessionId, toolName, result.result)
|
||||||
|
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||||
|
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||||
|
llmCalls++
|
||||||
|
}
|
||||||
|
|
||||||
|
is AgentToolkit.Dispatch.Silent -> {
|
||||||
|
// Невидимая системная тула (enable/disable_toolset): вызов не
|
||||||
|
// пишется в историю, промпт пересобирается на следующем callLlm
|
||||||
|
// (переход в состояние 3 — «Активные тулсеты»).
|
||||||
|
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
|
||||||
|
if (llmCalls++ >= MAX_LLM_CALLS) break
|
||||||
|
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
|
||||||
|
// чтобы пользователь получил данные, а не сырой маркер.
|
||||||
|
val stillTool = parseToolCall(answer)
|
||||||
|
return if (stillTool != null && lastToolResult != null) {
|
||||||
|
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
|
||||||
|
} else {
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
|
||||||
|
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
|
||||||
|
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
|
||||||
?: return "Ассистент не ответил (таймаут)"
|
?: return "Ассистент не ответил (таймаут)"
|
||||||
|
if (!isToolCall(answer)) {
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||||
chatDao.touchSession(sessionId)
|
chatDao.touchSession(sessionId)
|
||||||
|
}
|
||||||
return answer
|
return answer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
|
||||||
|
private fun legacySystemPrompt(): String {
|
||||||
|
val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски."
|
||||||
|
if (tools.isEmpty()) return base
|
||||||
|
return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}"
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// TODO(персона): промпт выберет пользователь
|
/** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */
|
||||||
const val SYSTEM_PROMPT =
|
const val MAX_TOOL_CALLS = 3
|
||||||
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
|
|
||||||
|
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
|
||||||
|
const val MAX_LLM_CALLS = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
|
||||||
|
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
|
||||||
|
*/
|
||||||
|
private val TOOL_CALL_RE =
|
||||||
|
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
data class ParsedToolCall(val name: String, val argsRaw: String?)
|
||||||
|
|
||||||
|
private fun parseToolCall(answer: String): ParsedToolCall? {
|
||||||
|
val m = TOOL_CALL_RE.find(answer) ?: return null
|
||||||
|
val argsRaw = m.groupValues[2].trim().ifBlank { null }
|
||||||
|
return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
|
||||||
|
|
||||||
|
/** Случайное имя сессии (UUID-подобное, 8 символов). */
|
||||||
|
private fun randomTitle(): String =
|
||||||
|
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
||||||
|
|
||||||
|
private fun logTool(sessionId: Long, toolName: String, result: String) {
|
||||||
|
pw.binom.viewmate.core.log("assistant", "тул $toolName (сессия $sessionId) → $result")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ class ChatDao(private val db: ChatDb) {
|
|||||||
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
|
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сессия по id, или null, если не существует. */
|
||||||
|
fun getSession(id: Long): Session? {
|
||||||
|
val cursor = db.readableDatabase.rawQuery(
|
||||||
|
"SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?",
|
||||||
|
arrayOf(id.toString()),
|
||||||
|
)
|
||||||
|
return cursor.use { c -> if (c.moveToFirst()) c.toSession() else null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сброс сессии: удалить все сообщения, сессия остаётся пустой. */
|
||||||
|
fun clearSession(id: Long) {
|
||||||
|
db.writableDatabase.delete(
|
||||||
|
ChatDb.TABLE_MESSAGES,
|
||||||
|
"session_id = ?",
|
||||||
|
arrayOf(id.toString()),
|
||||||
|
)
|
||||||
|
touchSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
fun renameSession(id: Long, title: String) {
|
fun renameSession(id: Long, title: String) {
|
||||||
db.writableDatabase.update(
|
db.writableDatabase.update(
|
||||||
ChatDb.TABLE_SESSIONS,
|
ChatDb.TABLE_SESSIONS,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import pw.binom.viewmate.core.media.JellyfinClient
|
||||||
|
import pw.binom.viewmate.core.media.JellyfinItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [MediaSource] поверх Jellyfin: search/item через [JellyfinClient].
|
||||||
|
* [userId] — кэш приложения (PhoneApp.userId, подтягивается при старте),
|
||||||
|
* передан провайдером: если к моменту вызова ещё не готов — [MediaSourceException]
|
||||||
|
* (исполнитель покажет модели «Поиск недоступен: ...», а не пахнет пустым результатом).
|
||||||
|
*/
|
||||||
|
class JellyfinMediaSource(
|
||||||
|
private val jellyfin: JellyfinClient,
|
||||||
|
private val userId: () -> String,
|
||||||
|
) : MediaSource {
|
||||||
|
|
||||||
|
override suspend fun search(query: String, limit: Int): List<MediaItem> {
|
||||||
|
val user = requireUserAvailable()
|
||||||
|
return jellyfin.search(user, query).take(limit).map { jellyfinToMediaItem(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun item(itemId: String): MediaItem? {
|
||||||
|
val user = requireUserAvailable()
|
||||||
|
return jellyfin.item(user, itemId)?.let { jellyfinToMediaItem(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireUserAvailable(): String =
|
||||||
|
userId().trim().ifBlank { throw MediaSourceException("нет пользователя Jellyfin (userId пуст)") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Модель Jellyfin → тул-элемент (JVM, тестируемо: поля, «постер есть», длительность в секундах). */
|
||||||
|
fun jellyfinToMediaItem(item: JellyfinItem): MediaItem = MediaItem(
|
||||||
|
id = item.Id,
|
||||||
|
name = item.Name?.trim()?.takeIf { it.isNotEmpty() } ?: item.Id,
|
||||||
|
type = item.Type,
|
||||||
|
year = item.ProductionYear,
|
||||||
|
overview = item.Overview,
|
||||||
|
durationSeconds = item.RunTimeTicks?.let { it / 10_000_000 },
|
||||||
|
hasPoster = item.ImageTags?.containsKey("Primary") == true,
|
||||||
|
)
|
||||||
@@ -1,64 +1,125 @@
|
|||||||
package pw.binom.viewmate.phone.agent
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
|
import kotlinx.coroutines.Dispatchers
|
||||||
import ai.koog.prompt.dsl.prompt
|
import kotlinx.coroutines.withContext
|
||||||
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
|
import kotlinx.serialization.Serializable
|
||||||
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
|
import kotlinx.serialization.encodeToString
|
||||||
import ai.koog.prompt.llm.LLMCapability
|
import kotlinx.serialization.json.Json
|
||||||
import ai.koog.prompt.llm.LLModel
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import ai.koog.prompt.llm.LLMProvider
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
|
* LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
|
||||||
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
|
* → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
|
||||||
|
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
|
||||||
|
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
|
||||||
*
|
*
|
||||||
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
|
* Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
|
||||||
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
|
* («Ассистент недоступен: …»), сам клиент не падает тихо.
|
||||||
*
|
|
||||||
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
|
|
||||||
*/
|
*/
|
||||||
class LlmClient(
|
interface LlmClient {
|
||||||
|
/**
|
||||||
|
* [messages] — история диалога (последнее сообщение — текущее обращение),
|
||||||
|
* [system] — системный промпт тулкита/настроек или null (не обязателен).
|
||||||
|
*/
|
||||||
|
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
|
||||||
|
* https://llm.binom.pw/v1/chat/completions.
|
||||||
|
*
|
||||||
|
* Koog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
|
||||||
|
* content на длинных историях (после tool-вызовов) — см. заметку
|
||||||
|
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
|
||||||
|
* модель отвечает стабильно, тул-цикл работает.
|
||||||
|
*
|
||||||
|
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
|
||||||
|
* [http] инжектируется (дефолт — свой клиент с таймаутами).
|
||||||
|
*/
|
||||||
|
class RemoteLlmClient(
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
private val baseUrl: String = "https://llm.binom.pw",
|
private val baseUrl: String = "https://llm.binom.pw",
|
||||||
private val model: String, // модель укажет пользователь
|
private val model: String,
|
||||||
) {
|
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build(),
|
||||||
|
) : LlmClient {
|
||||||
|
|
||||||
private val client: OpenAILLMClient = OpenAILLMClient(
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
apiKey = apiKey,
|
|
||||||
settings = OpenAIClientSettings(baseUrl = baseUrl),
|
@Serializable
|
||||||
httpClientFactory = OkHttpKoogHttpClient.Factory(),
|
private data class ChatMessageDto(
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val llmModel: LLModel = LLModel(
|
@Serializable
|
||||||
provider = LLMProvider.OpenAI,
|
private data class ChatRequest(
|
||||||
id = model,
|
val model: String,
|
||||||
capabilities = listOf(
|
val messages: List<ChatMessageDto>,
|
||||||
LLMCapability.Completion,
|
val max_tokens: Int = 1024,
|
||||||
LLMCapability.OpenAIEndpoint.Completions,
|
val temperature: Double = 0.3,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ChatResponse(
|
||||||
|
val choices: List<Choice> = emptyList(),
|
||||||
|
val error: ApiError? = null,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class Choice(val message: Message = Message())
|
||||||
|
@Serializable
|
||||||
|
data class Message(val content: String? = null)
|
||||||
|
@Serializable
|
||||||
|
data class ApiError(val message: String? = null)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
|
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
|
||||||
* System-сообщение, если задано, добавляется первым.
|
* System-сообщение, если задано, добавляется первым. TOOL-сообщения —
|
||||||
|
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
|
||||||
*/
|
*/
|
||||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
|
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||||
val prompt = prompt("assistant-chat") {
|
val dto = ArrayList<ChatMessageDto>()
|
||||||
if (system != null) {
|
if (system != null) dto += ChatMessageDto("system", system)
|
||||||
this.system(system)
|
|
||||||
}
|
|
||||||
for (msg in messages) {
|
for (msg in messages) {
|
||||||
when (msg.role) {
|
when (msg.role) {
|
||||||
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
|
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
|
||||||
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
|
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
|
||||||
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
|
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
|
||||||
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
|
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
|
||||||
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
|
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
|
||||||
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
|
|
||||||
ChatRole.TOOL -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return client.execute(prompt, llmModel).textContent()
|
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
|
||||||
|
|
||||||
|
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$baseUrl/v1/chat/completions")
|
||||||
|
.header("Authorization", "Bearer $apiKey")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.post(body.toRequestBody("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
http.newCall(request).execute().use { resp ->
|
||||||
|
val raw = resp.body?.string().orEmpty()
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
||||||
|
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
||||||
|
}
|
||||||
|
val text = runCatching {
|
||||||
|
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
||||||
|
}.getOrNull()
|
||||||
|
text?.trim() ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ object LlmDebug {
|
|||||||
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
|
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val client = LlmClient(apiKey = apiKey, model = model)
|
val client = RemoteLlmClient(apiKey = apiKey, model = model)
|
||||||
try {
|
try {
|
||||||
val reply = runBlockingSafe {
|
val reply = runBlockingSafe {
|
||||||
client.chat(
|
client.chat(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Персистентный выбор модели LLM ассистента ([LlmModelChoice]) —
|
||||||
|
* SharedPreferences «llm-choice». Дефолт (пусто/неизвестно) — [LlmModelChoice.REMOTE].
|
||||||
|
*
|
||||||
|
* Чистая логика парсинга — [LlmModelChoice.parse] (JVM-тестируемая);
|
||||||
|
* этот класс только обёртка над SharedPreferences.
|
||||||
|
*/
|
||||||
|
class LlmPrefs(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun current(): LlmModelChoice = LlmModelChoice.parse(prefs.getString(KEY_MODEL_CHOICE, null))
|
||||||
|
|
||||||
|
fun set(choice: LlmModelChoice) {
|
||||||
|
prefs.edit().putString(KEY_MODEL_CHOICE, choice.name).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFS_NAME = "llm-choice"
|
||||||
|
const val KEY_MODEL_CHOICE = "model_choice"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import com.google.ai.edge.litertlm.Backend
|
||||||
|
import com.google.ai.edge.litertlm.Content
|
||||||
|
import com.google.ai.edge.litertlm.Contents
|
||||||
|
import com.google.ai.edge.litertlm.Conversation
|
||||||
|
import com.google.ai.edge.litertlm.ConversationConfig
|
||||||
|
import com.google.ai.edge.litertlm.Engine
|
||||||
|
import com.google.ai.edge.litertlm.EngineConfig
|
||||||
|
import com.google.ai.edge.litertlm.ExperimentalApi
|
||||||
|
import com.google.ai.edge.litertlm.ExperimentalFlags
|
||||||
|
import com.google.ai.edge.litertlm.Message
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message)
|
||||||
|
|
||||||
|
/** Обрезает сообщение до 200 символов, оставляя читаемые (ASCII + кириллица). */
|
||||||
|
private fun sanitize(msg: String?): String {
|
||||||
|
if (msg == null) return "null"
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (ch in msg.take(200)) {
|
||||||
|
val c = ch.code
|
||||||
|
if ((c in 32..126) || (c >= 0x410 && c <= 0x44F)) sb.append(ch)
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp),
|
||||||
|
* переключается UI-блоком в настройках экрана «Телефон»:
|
||||||
|
* - [REMOTE] — сервер llm.binom.pw ([RemoteLlmClient]: OpenAI-совместимый HTTP);
|
||||||
|
* - [LOCAL] — Gemma 4 E2B на телефоне ([LocalLlmClient], LiteRT-LM, GPU/CPU).
|
||||||
|
*/
|
||||||
|
enum class LlmModelChoice {
|
||||||
|
REMOTE, LOCAL;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Значение из настроек; пустое/неизвестное имя → [REMOTE] (дефолт). */
|
||||||
|
fun parse(name: String?): LlmModelChoice =
|
||||||
|
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: REMOTE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemma 4 E2B (LiteRT-LM): локальный LLM на телефоне.
|
||||||
|
* Модель — файл [FILE_NAME] в getExternalFilesDir(null) телефона;
|
||||||
|
* докачивается внутри приложения (см. [GemmaModelDownloader]).
|
||||||
|
* API-ноты: docs/litertlm-openassistant-notes.md в репо.
|
||||||
|
*/
|
||||||
|
object Gemma4E2B {
|
||||||
|
const val FILE_NAME = "gemma-4-E2B-it.litertlm"
|
||||||
|
const val SHA256 = "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c"
|
||||||
|
|
||||||
|
/** Размер файла для UI (2 588 147 712 байта). */
|
||||||
|
const val SIZE_HUMAN = "≈2.5 ГБ"
|
||||||
|
|
||||||
|
/** Источники скачивания по приоритету: HuggingFace (открытый), затем зеркало. */
|
||||||
|
val DOWNLOAD_URLS = listOf(
|
||||||
|
"https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm",
|
||||||
|
"https://data.vayunmathur.com/models/gemma-4-E2B-it.litertlm",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфиг движка — точь-в-точь как в референсе vayunmathur/Modern-Apps
|
||||||
|
* (InferenceService.kt): GPU для основного и vision, CPU для аудио.
|
||||||
|
* Ключ: cacheDir должен быть системным applicationContext.cacheDir —
|
||||||
|
* GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров;
|
||||||
|
* свой подкаталог в filesDir приводил к падению на delegate_kernel.cc.
|
||||||
|
*/
|
||||||
|
fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
|
||||||
|
modelPath = modelFile.absolutePath,
|
||||||
|
// NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497)
|
||||||
|
// в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на
|
||||||
|
// этом HONOR гоняет весь движок (включая vision-сабаграф) на WebGPU; ранее
|
||||||
|
// мы режут vision на CPU из-за 128 МБ-лимита буфера, но чистый текст-чат
|
||||||
|
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому
|
||||||
|
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
|
||||||
|
// картинке — вернём vision на CPU точечно.
|
||||||
|
backend = Backend.GPU(),
|
||||||
|
visionBackend = Backend.GPU(),
|
||||||
|
audioBackend = Backend.CPU(),
|
||||||
|
cacheDir = cacheDir.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Чистые вспомогательные (JVM-тестируемые) ----------
|
||||||
|
|
||||||
|
/** hex SHA-256 содержимого файла потоково (по 64 КБ — файл ~2.5 ГБ). */
|
||||||
|
fun sha256HexOfFile(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val readSize = input.read(buf)
|
||||||
|
if (readSize < 0) break
|
||||||
|
digest.update(buf, 0, readSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true, если [file] существует и его SHA-256 совпадает с [expectedHex]. */
|
||||||
|
fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
|
||||||
|
file.isFile && sha256HexOfFile(file).equals(expectedHex, ignoreCase = true)
|
||||||
|
|
||||||
|
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
|
||||||
|
class LocalLlmPrompt(
|
||||||
|
val systemInstruction: String?,
|
||||||
|
val initialMessages: List<Message>,
|
||||||
|
val incoming: Message,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
|
||||||
|
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
|
||||||
|
.joinToString("") { it.text }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
|
||||||
|
* - USER → [Message.user], ASSISTANT → [Message.model];
|
||||||
|
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
|
||||||
|
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет
|
||||||
|
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж);
|
||||||
|
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
|
||||||
|
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
|
||||||
|
* - пустые content отбрасываются.
|
||||||
|
*
|
||||||
|
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL:
|
||||||
|
* оно уходит в sendMessageAsync, остальное — история (initialMessages).
|
||||||
|
* Если последние сообщения только модельные/история пуста — incoming = «Продолжай.»
|
||||||
|
* (аналог fallback [RemoteLlmClient]).
|
||||||
|
*/
|
||||||
|
fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt {
|
||||||
|
val systemParts = ArrayList<String>()
|
||||||
|
system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
|
||||||
|
val mapped = ArrayList<Message>()
|
||||||
|
for (msg in messages) {
|
||||||
|
val text = msg.content
|
||||||
|
when (msg.role) {
|
||||||
|
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
|
||||||
|
ChatRole.USER -> if (text.isNotBlank()) mapped += Message.user(Contents.of(text))
|
||||||
|
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(text))
|
||||||
|
ChatRole.TOOL -> if (text.isNotBlank()) {
|
||||||
|
mapped += Message.user(Contents.of("Результат тула ${msg.toolCallId ?: "?"}: $text"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val last = messages.lastOrNull { it.content.isNotBlank() }
|
||||||
|
val incoming: Message
|
||||||
|
val initial: List<Message>
|
||||||
|
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
|
||||||
|
initial = mapped.dropLast(1)
|
||||||
|
incoming = mapped.last()
|
||||||
|
} else {
|
||||||
|
initial = mapped
|
||||||
|
incoming = Message.user(Contents.of("Продолжай."))
|
||||||
|
}
|
||||||
|
return LocalLlmPrompt(
|
||||||
|
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
|
||||||
|
initialMessages = initial,
|
||||||
|
incoming = incoming,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Скачивание модели ----------
|
||||||
|
|
||||||
|
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
|
||||||
|
sealed interface LocalLlmModelState {
|
||||||
|
/** Модель не скачана (или ещё не скачивалась). */
|
||||||
|
object Idle : LocalLlmModelState
|
||||||
|
|
||||||
|
/** Скачивание в пути; [percent] — 0..99. */
|
||||||
|
data class Downloading(val percent: Int) : LocalLlmModelState
|
||||||
|
|
||||||
|
/** Модель скачана и проверена (SHA-256 совпал при скачивании). */
|
||||||
|
object Ready : LocalLlmModelState
|
||||||
|
|
||||||
|
/** Ошибка скачивания: [message]. */
|
||||||
|
data class Error(val message: String) : LocalLlmModelState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивание Gemma 4 E2B ([Gemma4E2B.DOWNLOAD_URLS] по порядку):
|
||||||
|
* OkHttp-стриминг в файл .part, прогресс в процентах через колбэк,
|
||||||
|
* SHA-256-контроль после загрузки (несовпал — удалить и взять следующий
|
||||||
|
* исходник), после успешного — перенос на [target]. Прогресс — из IO-потока
|
||||||
|
* (колбэк должен быть потоковезбёчным; в PhoneApp — запись в StateFlow).
|
||||||
|
*/
|
||||||
|
class GemmaModelDownloader(
|
||||||
|
private val target: File,
|
||||||
|
private val urls: List<String> = Gemma4E2B.DOWNLOAD_URLS,
|
||||||
|
private val expectedSha256: String = Gemma4E2B.SHA256,
|
||||||
|
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
|
.build(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Скачать модель в [target]. Бросает [IOException] «Модель не скачалась…»
|
||||||
|
* (все источники исчерпаны / контрольная сумма). [onProgressPercent] —
|
||||||
|
* 0..99 во время скачивания (вызывается из IO-потока).
|
||||||
|
*/
|
||||||
|
suspend fun download(onProgressPercent: (Int) -> Unit) {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val partial = File(target.parentFile, target.name + ".part")
|
||||||
|
var lastError: String? = null
|
||||||
|
for (url in urls) {
|
||||||
|
var ok: Boolean
|
||||||
|
try {
|
||||||
|
ok = downloadFrom(url, partial, onProgressPercent)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ok = false
|
||||||
|
lastError = e.message ?: e.toString()
|
||||||
|
}
|
||||||
|
log("llm", "скачивание модели из $url: ${if (ok) "ок" else "не удалось ($lastError)"}")
|
||||||
|
if (ok) {
|
||||||
|
log("llm", "модель скачана: ${target.length()} байт, SHA-256 совпадает")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
partial.delete()
|
||||||
|
}
|
||||||
|
partial.delete()
|
||||||
|
throw IOException("Модель не скачалась (все источники): $lastError — повторите позже")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadFrom(
|
||||||
|
url: String,
|
||||||
|
partial: File,
|
||||||
|
onProgressPercent: (Int) -> Unit,
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
http.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
log("llm", "HTTP ${response.code} с $url")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
val body = response.body ?: return@withContext false
|
||||||
|
val totalBytes = body.contentLength()
|
||||||
|
var received = 0L
|
||||||
|
var lastPercent = -1
|
||||||
|
partial.outputStream().use { out ->
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
val buf = ByteArray(256 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
out.write(buf, 0, n)
|
||||||
|
received += n
|
||||||
|
val percent = if (totalBytes > 0) ((received * 100) / totalBytes).toInt()
|
||||||
|
else ((received / (4 * 1024 * 1024)) * 25).toInt().coerceAtMost(99)
|
||||||
|
if (percent > lastPercent) {
|
||||||
|
lastPercent = percent
|
||||||
|
log("llm", "модель: скачано ${received / (1024 * 1024)} МБ" +
|
||||||
|
(if (totalBytes > 0) " ($percent%)" else ""))
|
||||||
|
onProgressPercent(percent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sha256OfFileMatches(partial, expectedSha256)) {
|
||||||
|
log("llm", "SHA-256 модели не совпал — перезаписываем")
|
||||||
|
partial.delete()
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
partial.renameTo(target)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Клиент ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве
|
||||||
|
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md).
|
||||||
|
*
|
||||||
|
* Жизненный цикл: [Engine] создаётся один раз (лениво, при первом [chat]),
|
||||||
|
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
||||||
|
* новый Conversation (своей истории у LiteRT-LM нет — она в наших
|
||||||
|
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским
|
||||||
|
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
||||||
|
* пользователю, падение приложения исключено.
|
||||||
|
*
|
||||||
|
* Модель-файл: [modelFile] (getExternalFilesDir(null)); [cacheDir] —
|
||||||
|
* кэш-каталог движка (cacheDir, не tmpDir — tmp-файлы удаляются системой).
|
||||||
|
*/
|
||||||
|
class LocalLlmClient(
|
||||||
|
private val modelFile: File,
|
||||||
|
private val cacheDir: File,
|
||||||
|
) : LlmClient {
|
||||||
|
|
||||||
|
private val engineLock = Any()
|
||||||
|
@Volatile
|
||||||
|
private var engine: Engine? = null
|
||||||
|
|
||||||
|
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */
|
||||||
|
fun modelDownloaded(): Boolean = modelFile.isFile
|
||||||
|
|
||||||
|
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||||
|
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
|
||||||
|
val engine = ensureEngine()
|
||||||
|
log("llm", "chat: движок готов, строю промпт")
|
||||||
|
val prompt = buildLocalLlmPrompt(messages, system)
|
||||||
|
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text().take(60)}")
|
||||||
|
val conversation: Conversation = synchronized(engineLock) {
|
||||||
|
engine.createConversation(
|
||||||
|
ConversationConfig(
|
||||||
|
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) },
|
||||||
|
initialMessages = prompt.initialMessages,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
log("llm", "chat: conversation создан")
|
||||||
|
try {
|
||||||
|
var text = ""
|
||||||
|
var chunks = 0
|
||||||
|
var tokens = 0
|
||||||
|
val t0 = System.currentTimeMillis()
|
||||||
|
var firstTokenAt = 0L
|
||||||
|
withTimeout(120_000) {
|
||||||
|
// NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт
|
||||||
|
// emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом
|
||||||
|
// dex null — артефакт маски $default, не аргумент.
|
||||||
|
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
|
||||||
|
chunks++
|
||||||
|
val chunkText = messageChunk.text()
|
||||||
|
text += chunkText
|
||||||
|
if (chunkText.isNotEmpty()) {
|
||||||
|
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
|
||||||
|
tokens += 1
|
||||||
|
}
|
||||||
|
if (chunks <= 3 || chunks % 20 == 0) {
|
||||||
|
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val totalMs = System.currentTimeMillis() - t0
|
||||||
|
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
|
||||||
|
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
|
||||||
|
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)")
|
||||||
|
return text.trim()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
log("llm", "chat: отменено")
|
||||||
|
runCatching { conversation.cancelProcess() }
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
|
||||||
|
log("llm", "chat: EXC msg=${sanitize(e.message)}")
|
||||||
|
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
|
||||||
|
} finally {
|
||||||
|
runCatching { conversation.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
|
||||||
|
fun close() {
|
||||||
|
val eng = synchronized(engineLock) {
|
||||||
|
val e = engine
|
||||||
|
engine = null
|
||||||
|
e
|
||||||
|
}
|
||||||
|
runCatching { eng?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */
|
||||||
|
@OptIn(ExperimentalApi::class)
|
||||||
|
private fun applyExperimentalFlags() {
|
||||||
|
// NOTE 2026-08-23 (porfiry): ранее выключали спекуляцию, потому что статический
|
||||||
|
// TopK-сэмплер при enableSpeculativeDecoding=false тянет OpenCL-драйвер,
|
||||||
|
// которого нет в app-namespace ("Can not find OpenCL library"). Автор включил
|
||||||
|
// её TRUE: тогда движок использует статический WebGPU-сэмплер (он на HONOR
|
||||||
|
// работает — оригинал идёт, TTFB ~0.8с тёплый). Берём его флаг как есть.
|
||||||
|
ExperimentalFlags.enableSpeculativeDecoding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
|
||||||
|
private fun ensureEngine(): Engine {
|
||||||
|
val cached = engine
|
||||||
|
if (cached != null) return cached
|
||||||
|
return synchronized(engineLock) {
|
||||||
|
val existing = engine
|
||||||
|
if (existing != null) return existing
|
||||||
|
if (!modelFile.isFile) {
|
||||||
|
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
|
||||||
|
}
|
||||||
|
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
|
||||||
|
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
|
||||||
|
}
|
||||||
|
applyExperimentalFlags()
|
||||||
|
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
|
||||||
|
// скомпилированные шейдеры в cacheDir; если каталога нет — движок
|
||||||
|
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
|
||||||
|
// в кэш не пишет, поэтому работал и без mkdirs.
|
||||||
|
cacheDir.mkdirs()
|
||||||
|
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir))
|
||||||
|
try {
|
||||||
|
created.initialize()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { created.close() }
|
||||||
|
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
|
||||||
|
}
|
||||||
|
log("llm", "локальный движок инициализирован: ${modelFile.name}")
|
||||||
|
engine = created
|
||||||
|
created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Медиа-элемент для тул: минимум, что нужно модели.
|
||||||
|
* Не зависит от моделей lib-core — JVM, тестируемо фейками.
|
||||||
|
*/
|
||||||
|
data class MediaItem(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val type: String?,
|
||||||
|
val year: Int?,
|
||||||
|
val overview: String?,
|
||||||
|
val durationSeconds: Long?,
|
||||||
|
val hasPoster: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Источник медиа для тулсета: поиск + детали элемента. */
|
||||||
|
interface MediaSource {
|
||||||
|
/** Поиск по [query]: до [limit] результатов. */
|
||||||
|
suspend fun search(query: String, limit: Int): List<MediaItem>
|
||||||
|
/** Детали элемента по [itemId]; null — не найден. */
|
||||||
|
suspend fun item(itemId: String): MediaItem?
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ошибка источника: нет пользователя Jellyfin, сеть недоступна и т.п. */
|
||||||
|
class MediaSourceException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить «включить на очках» (тул media_play): проверки + [pw.binom.viewmate.core.phone.PhoneActions.watch]
|
||||||
|
* + звук на телефоне — уровень приложения. Возвращает человекочитаемый текст.
|
||||||
|
*/
|
||||||
|
fun interface MediaPlayer {
|
||||||
|
suspend fun play(itemId: String, seekMs: Long): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лимит поиска по умолчанию, когда модель его не указала. */
|
||||||
|
const val MEDIA_SEARCH_DEFAULT_LIMIT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определение тулсета media: сигнатуры БЕЗ префикса (механика добавляет media_).
|
||||||
|
* Описания злые и короткие — маленькая модель (TASK-agent-tools, пункт 10).
|
||||||
|
*/
|
||||||
|
val MEDIA_TOOLSET = Toolset(
|
||||||
|
name = "media",
|
||||||
|
description = "медиа и воспроизведение: поиск, детали, запуск на очках (search/info/play)",
|
||||||
|
tools = listOf(
|
||||||
|
ToolSignature("search", "Поиск фильмов/сериалов по названию. Возвращает: id, название, год, тип.", args = listOf("query", "limit?")),
|
||||||
|
ToolSignature("info", "Детали элемента по id: название, тип, год, описание, длительность, постер.", args = listOf("id")),
|
||||||
|
ToolSignature("play", "Включить просмотр на очках: видео скачано на очки, звук — на телефон.", args = listOf("id", "seek_ms?")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исполнитель тул media: валидация аргументов + форматирование текста.
|
||||||
|
* Источники: [media] (Jellyfin) и [player] («включить на очках» — уровень приложения).
|
||||||
|
* Механика вызывает [execute] синхронно — а тулы suspend: мост через runBlocking
|
||||||
|
* (вызов и так на Dispatchers.IO, из ассистентского цикла).
|
||||||
|
*/
|
||||||
|
class MediaToolExecutor(
|
||||||
|
private val media: MediaSource,
|
||||||
|
private val player: MediaPlayer,
|
||||||
|
) : ToolsetExecutor {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val OVERVIEW_MAX_CHARS = 300
|
||||||
|
|
||||||
|
fun exceptionMessage(e: Throwable): String = e.message?.trim()?.takeIf { it.isNotEmpty() } ?: e.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute(toolset: String, tool: String, args: Map<String, String>): String = when (tool) {
|
||||||
|
"media_search" -> search(args["query"], args["limit"])
|
||||||
|
"media_info" -> info(args["id"])
|
||||||
|
"media_play" -> play(args["id"], args["seek_ms"])
|
||||||
|
else -> "Неизвестный тул \"$tool\". Доступны: media_search, media_info, media_play."
|
||||||
|
}
|
||||||
|
|
||||||
|
/** media_search: строки «id: Название (Год) — Тип», до limit (дефолт [MEDIA_SEARCH_DEFAULT_LIMIT]). */
|
||||||
|
private fun search(rawQuery: String?, rawLimit: String?): String {
|
||||||
|
val query = rawQuery?.trim().orEmpty()
|
||||||
|
if (query.isEmpty()) {
|
||||||
|
return "media_search: нет аргумента \"query\". Схема: media_search|<query> [limit=<n>]"
|
||||||
|
}
|
||||||
|
val limit = rawLimit?.trim()?.toIntOrNull()?.coerceIn(1, 20) ?: MEDIA_SEARCH_DEFAULT_LIMIT
|
||||||
|
val items: List<MediaItem> = runCatching { runBlocking { media.search(query, limit) } }
|
||||||
|
.getOrElse { return "Поиск недоступен: ${exceptionMessage(it)}" }
|
||||||
|
if (items.isEmpty()) return "Ничего не найдено по запросу «$query»."
|
||||||
|
return items.joinToString("\n") { row ->
|
||||||
|
val year = row.year?.let { " ($it)" }.orEmpty()
|
||||||
|
val type = row.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
|
||||||
|
"${row.id}: ${row.name}$year — $type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** media_info: «Название» — Тип (Год), описание (≤ [OVERVIEW_MAX_CHARS]), длительность, постер. */
|
||||||
|
private fun info(rawId: String?): String {
|
||||||
|
val id = rawId?.trim().orEmpty()
|
||||||
|
if (id.isEmpty()) {
|
||||||
|
return "media_info: нет аргумента \"id\". Схема: media_info|<id>"
|
||||||
|
}
|
||||||
|
val item: MediaItem? = runCatching { runBlocking { media.item(id) } }
|
||||||
|
.getOrElse { return "Детали недоступны: ${exceptionMessage(it)}" }
|
||||||
|
if (item == null) return "Элемент \"$id\" не найден."
|
||||||
|
return infoText(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** media_play: id обязателен, seek_ms — опционально (целые миллисекунды); выполняем через [player]. */
|
||||||
|
private fun play(rawId: String?, rawSeek: String?): String {
|
||||||
|
val id = rawId?.trim().orEmpty()
|
||||||
|
if (id.isEmpty()) {
|
||||||
|
return "media_play: нет аргумента \"id\". Схема: media_play|<id> [seek_ms=<n>]"
|
||||||
|
}
|
||||||
|
val seekMs = when (val s = rawSeek?.trim().orEmpty()) {
|
||||||
|
"" -> 0L
|
||||||
|
else -> s.toLongOrNull()?.coerceAtLeast(0L)
|
||||||
|
?: return "media_play: аргумент \"seek_ms\" должен быть числом (миллисекунды)."
|
||||||
|
}
|
||||||
|
return runCatching { runBlocking { player.play(id, seekMs) } }
|
||||||
|
.getOrElse { "Ошибка воспроизведения: ${exceptionMessage(it)}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun infoText(item: MediaItem): String {
|
||||||
|
val year = item.year?.let { " ($it)" }.orEmpty()
|
||||||
|
val type = item.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
|
||||||
|
return buildList {
|
||||||
|
add("«${item.name}» — ${type}$year")
|
||||||
|
val overview = item.overview?.trim()
|
||||||
|
if (!overview.isNullOrBlank()) {
|
||||||
|
add("Описание: " + if (overview.length > OVERVIEW_MAX_CHARS) overview.take(OVERVIEW_MAX_CHARS).trimEnd() + "…" else overview)
|
||||||
|
}
|
||||||
|
item.durationSeconds?.takeIf { it > 0 }?.let { add("Длительность: ${durationText(it)}") }
|
||||||
|
add("Постер: " + if (item.hasPoster) "есть" else "нет")
|
||||||
|
}.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Секунды в человекочитаемом виде: ≥ 60 сек — минуты (с секундами в скобках). */
|
||||||
|
private fun durationText(seconds: Long): String {
|
||||||
|
val minutes = seconds / 60
|
||||||
|
return if (minutes > 0) "${minutes} мин. (${seconds} сек.)" else "$seconds сек."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,29 @@ data class ChatMessage(
|
|||||||
val toolCalls: List<ToolCall>,
|
val toolCalls: List<ToolCall>,
|
||||||
val attachments: List<Attachment>,
|
val attachments: List<Attachment>,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
)
|
) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Фабрика для сообщений до записи в БД (тесты, сборка промпта):
|
||||||
|
* id/sessionId/createdAt — 0, пустые toolCalls/attachments.
|
||||||
|
*/
|
||||||
|
fun of(
|
||||||
|
role: ChatRole,
|
||||||
|
content: String,
|
||||||
|
toolCallId: String? = null,
|
||||||
|
toolCalls: List<ToolCall> = emptyList(),
|
||||||
|
) = ChatMessage(
|
||||||
|
id = 0L,
|
||||||
|
sessionId = 0L,
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
toolCallId = toolCallId,
|
||||||
|
toolCalls = toolCalls,
|
||||||
|
attachments = emptyList(),
|
||||||
|
createdAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
|
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
|
||||||
internal val agentJson: Json = Json { ignoreUnknownKeys = true }
|
internal val agentJson: Json = Json { ignoreUnknownKeys = true }
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скилл = знание: «КАК пользоваться». Триггер (одна строка) + тело (markdown:
|
||||||
|
* шаги, команды, питфоллы). Пассивный: живёт в файлах, читается по требованию
|
||||||
|
* через tuл read_skill — содержимое в промпт НЕ попадает (ленивое чтение).
|
||||||
|
*/
|
||||||
|
data class Skill(
|
||||||
|
val name: String,
|
||||||
|
val trigger: String,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Читает скиллы из папки `files/skills/` (внутренняя files-папка приложения).
|
||||||
|
* Формат файла:
|
||||||
|
* ```
|
||||||
|
* ---
|
||||||
|
* name: media
|
||||||
|
* description: поиск фильмов, детали, запуск/управление просмотром.
|
||||||
|
* ---
|
||||||
|
* тело скилла (markdown)...
|
||||||
|
* ```
|
||||||
|
* Без frontmatter — имя берётся из имени файла, всё содержимое = тело.
|
||||||
|
* Нетовой папки или пустой каталог — пустой список (механика работает на
|
||||||
|
* пустом каталоге; реальные скиллы кладутся позже).
|
||||||
|
*/
|
||||||
|
class SkillRepository(private val directory: File) {
|
||||||
|
private val extensions = setOf("md", "markdown", "txt")
|
||||||
|
|
||||||
|
fun loadAll(): List<Skill> {
|
||||||
|
val files = directory.listFiles { f -> f.isFile && f.extension.lowercase() in extensions }
|
||||||
|
?: return emptyList()
|
||||||
|
return files
|
||||||
|
.sortedBy { it.name }
|
||||||
|
.mapNotNull { parse(it) }
|
||||||
|
.distinctBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(file: File): Skill? {
|
||||||
|
val text = runCatching { file.readText() }.getOrNull() ?: return null
|
||||||
|
val lines = text.lines()
|
||||||
|
var name = file.nameWithoutExtension
|
||||||
|
var trigger = ""
|
||||||
|
var body = text.trim()
|
||||||
|
if (lines.isNotEmpty() && lines[0].trim() == "---") {
|
||||||
|
// Фронтматтер: блок строками key: value между первой и второй строкой ---.
|
||||||
|
val close = lines.drop(1).indexOfFirst { it.trim() == "---" }
|
||||||
|
if (close >= 0) {
|
||||||
|
val fm = lines.subList(1, close + 1)
|
||||||
|
fmValue(fm, "name")?.let { name = it }
|
||||||
|
trigger = fmValue(fm, "description") ?: ""
|
||||||
|
body = lines.drop(close + 2).joinToString("\n").trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Skill(name = name, trigger = trigger, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Значение ключа из frontmatter (`key: значение`); пустое/отсутствующее — null. */
|
||||||
|
private fun fmValue(fm: List<String>, key: String): String? =
|
||||||
|
fm.firstOrNull { it.startsWith("$key:") }
|
||||||
|
?.substringAfter(':')
|
||||||
|
?.trim()
|
||||||
|
?.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индекс скиллов (имя + триггер) для промпта; полное содержимое — только
|
||||||
|
* через [readFull] лениво (вызов read_skill). Индекс идёт в промпт; когда
|
||||||
|
* связанный тулсет активен, свой скилл (имя = имя тулсета) из индекса УБИРАЕТСЯ.
|
||||||
|
*/
|
||||||
|
class SkillRegistry(skills: List<Skill>) {
|
||||||
|
private val byName: Map<String, Skill> = skills.associateBy { it.name.lowercase() }
|
||||||
|
|
||||||
|
/** Все имена (стабильный порядок). */
|
||||||
|
fun names(): List<String> = byName.values.map { it.name }
|
||||||
|
|
||||||
|
fun has(name: String): Boolean = byName.containsKey(name.lowercase())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строки индекса «имя — триггер» (одна строка на скилл). [excluded] —
|
||||||
|
* имена скиллов, которых в индексе не должно быть (например, скилл
|
||||||
|
* активного тулсета: искушение удалить, а не запретить).
|
||||||
|
*/
|
||||||
|
fun index(excluded: Set<String> = emptySet()): List<String> =
|
||||||
|
byName.values
|
||||||
|
.filter { !excluded.contains(it.name.lowercase()) }
|
||||||
|
.sortedBy { it.name }
|
||||||
|
.map { s -> s.trigger.takeIf { it.isNotBlank() }?.let { "${s.name} — $it" } ?: s.name }
|
||||||
|
|
||||||
|
/** Полное содержимое скилла (триггер + тело) — результат read_skill. */
|
||||||
|
fun readFull(name: String): String? =
|
||||||
|
byName[name.lowercase()]?.let { s ->
|
||||||
|
buildString {
|
||||||
|
append("Скилл \"").append(s.name).append("\"\n")
|
||||||
|
if (s.trigger.isNotBlank()) append("Триггер: ").append(s.trigger).append('\n')
|
||||||
|
append("\n")
|
||||||
|
append(s.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тулсет = набор тул (активные руки, «ЧТО МОЖНО»). Все тулы набора несут
|
||||||
|
* префикс ИМЕНИ набора: media_search, media_info — префикс = маршрутизация
|
||||||
|
* (система видит media_* → знает набор и сигнатуры).
|
||||||
|
*
|
||||||
|
* Аргументы сигнатуры описываются ИМЕНАМИ: «query» — обязательный,
|
||||||
|
* «seek_ms?» — опциональный («?»). Описание тула в промпте одной строкой:
|
||||||
|
* имя + что делает + аргументы — компактно для маленькой модели.
|
||||||
|
*/
|
||||||
|
data class ToolSignature(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val args: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
/** Обязательные аргументы (без маркера «?»). */
|
||||||
|
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
|
||||||
|
|
||||||
|
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
|
||||||
|
fun argNames(): List<String> = args.map { it.substringBefore('?') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набор тул: имя (одновременно префикс), описание для каталога, список тул
|
||||||
|
* (сигнатуры БЕЗ префикса — префикс добавляется при маршрутизации).
|
||||||
|
*/
|
||||||
|
data class Toolset(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val tools: List<ToolSignature>,
|
||||||
|
) {
|
||||||
|
/** Полное имя тула (с префиксом): media + search → media_search. */
|
||||||
|
fun toolFull(signature: ToolSignature): String = "${name}_${signature.name}"
|
||||||
|
|
||||||
|
/** Найти сигнатуру по полному имени (media_search → search). */
|
||||||
|
fun signatureOf(fullName: String): ToolSignature? =
|
||||||
|
tools.firstOrNull { toolFull(it) == fullName }
|
||||||
|
|
||||||
|
/** Строка каталога: «media — управление медиа и воспроизведением (search/info/play)». */
|
||||||
|
fun catalogLine(): String = "$name — $description"
|
||||||
|
|
||||||
|
/** Строки описаний тул для промпта (секция «Активные тулсеты»). */
|
||||||
|
fun toolLines(): List<String> = tools.map { s ->
|
||||||
|
val argsPart = if (s.args.isEmpty()) "" else "|" + s.args.joinToString(",")
|
||||||
|
"${name}_${s.name}$argsPart — ${s.description}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Исполнитель тул набора: (набор, тул-имя (с префиксом), аргументы) → строка результата. */
|
||||||
|
fun interface ToolsetExecutor {
|
||||||
|
fun execute(toolset: String, tool: String, args: Map<String, String>): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат enable_toolset. */
|
||||||
|
sealed class EnableResult {
|
||||||
|
/** Активен (при повторном вызове уже активен — таймаут продлён). */
|
||||||
|
class Activated(val toolset: String, val alreadyActive: Boolean) : EnableResult()
|
||||||
|
/** Неизвестное имя: сообщение для модели («набора X нет, доступны: ...»). */
|
||||||
|
class Unknown(val name: String, val message: String) : EnableResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат disable_toolset — всегда не ошибка (идемпотентно и прощающе). */
|
||||||
|
sealed class DisableResult {
|
||||||
|
class Disabled(val toolset: String) : DisableResult()
|
||||||
|
/** Набор не был активен — информационно, не ошибка. */
|
||||||
|
class NotActive(val message: String) : DisableResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр тулсетов + активность НА СЕССИЮ (сессия → набор → deadline).
|
||||||
|
*
|
||||||
|
* - Не персистится: перезапуск приложения = всё сброшено.
|
||||||
|
* - Таймаут: [DEFAULT_TIMEOUT_MS] (10 минут) с последнего вызова тулы набора;
|
||||||
|
* enable и каждый успешный вызов [touch] продлевают.
|
||||||
|
* - Проверка ТОЛЬКО при сборке промпта / диспаче — никаких фоновых таймеров:
|
||||||
|
* просроченные записи чистятся лениво в [activeNames]/[isActive].
|
||||||
|
*
|
||||||
|
* Часы и таймаут инжектируемые — юнит-тесты не ждут реальные 10 минут.
|
||||||
|
*/
|
||||||
|
class ToolsetRegistry(
|
||||||
|
val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Дефолтный таймаут активности: 10 минут. */
|
||||||
|
const val DEFAULT_TIMEOUT_MS = 10 * 60_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val toolsets = LinkedHashMap<String, Toolset>()
|
||||||
|
private val executors = HashMap<String, ToolsetExecutor>()
|
||||||
|
/** сессия → (набор → deadline, epoch-мс). Только в памяти. */
|
||||||
|
private val active = ConcurrentHashMap<Long, ConcurrentHashMap<String, Long>>()
|
||||||
|
|
||||||
|
/** Зарегистрировать набор (опционально с исполнителем его тул). */
|
||||||
|
fun register(toolset: Toolset, executor: ToolsetExecutor? = null) {
|
||||||
|
synchronized(this) {
|
||||||
|
toolsets[toolset.name] = toolset
|
||||||
|
if (executor != null) executors[toolset.name] = executor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Имена всех наборов каталога. */
|
||||||
|
fun names(): List<String> = synchronized(this) { toolsets.keys.toList() }
|
||||||
|
|
||||||
|
fun toolset(name: String): Toolset? = synchronized(this) { toolsets[name] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Префиксная маршрутизация: имя тула с префиксом набора (media_search → media).
|
||||||
|
* Префикс = «имени_».
|
||||||
|
*/
|
||||||
|
fun findByPrefix(fullToolName: String): Toolset? =
|
||||||
|
synchronized(this) {
|
||||||
|
toolsets.entries.firstOrNull { (n, _) -> fullToolName.startsWith(n + "_") }?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun executor(name: String): ToolsetExecutor? = synchronized(this) { executors[name] }
|
||||||
|
|
||||||
|
/** Строки каталога: «media — ...» (по одной строке на набор). */
|
||||||
|
fun catalogLines(): List<String> = synchronized(this) { toolsets.values.map { it.catalogLine() } }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Активные наборы сессии (deadline > now). Просроченные записи чистятся
|
||||||
|
* здесь же (ленивая проверка, нет фона). Порядок = порядок каталога.
|
||||||
|
*/
|
||||||
|
fun activeNames(sessionId: Long): List<String> =
|
||||||
|
synchronized(this) {
|
||||||
|
val session = active[sessionId] ?: return emptyList()
|
||||||
|
val now = clock()
|
||||||
|
val expired = session.entries.filter { it.value <= now }.map { it.key }
|
||||||
|
expired.forEach { session.remove(it) }
|
||||||
|
if (session.isEmpty()) active.remove(sessionId)
|
||||||
|
toolsets.keys.filter { session.containsKey(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Активен ли набор в сессии (с очисткой просроченных). */
|
||||||
|
fun isActive(sessionId: Long, name: String): Boolean = activeNames(sessionId).contains(name)
|
||||||
|
|
||||||
|
/** Сигнатура тула, если тул известного набора (для валидации аргументов). */
|
||||||
|
fun signatureOf(fullToolName: String): ToolSignature? =
|
||||||
|
findByPrefix(fullToolName)?.signatureOf(fullToolName)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Активировать набор в сессии (идемпотентно: повторный enable = продление
|
||||||
|
* таймаута, не ошибка). Deadline = now + timeout.
|
||||||
|
*/
|
||||||
|
fun enable(sessionId: Long, name: String): EnableResult {
|
||||||
|
val ts = toolset(name) ?: return EnableResult.Unknown(
|
||||||
|
name = name,
|
||||||
|
message = "Набора \"$name\" нет. " + when {
|
||||||
|
toolsets.isNotEmpty() -> "Доступны: " + names().joinToString(", ")
|
||||||
|
else -> "Доступных тулсетов нет."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
synchronized(this) {
|
||||||
|
val session = active.computeIfAbsent(sessionId) { ConcurrentHashMap() }
|
||||||
|
val already = session.containsKey(ts.name)
|
||||||
|
session[ts.name] = clock() + timeoutMs
|
||||||
|
return EnableResult.Activated(ts.name, already)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снять набор в сессии (идемпотентно: «нет активных наборов» — не ошибка).
|
||||||
|
*/
|
||||||
|
fun disable(sessionId: Long, name: String): DisableResult {
|
||||||
|
synchronized(this) {
|
||||||
|
val session = active[sessionId]
|
||||||
|
if (session != null && session.remove(name) != null) return DisableResult.Disabled(name)
|
||||||
|
val anyActive = if (session != null && session.isNotEmpty()) {
|
||||||
|
val now = clock()
|
||||||
|
session.entries.removeAll { it.value <= now }
|
||||||
|
session.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val msg = if (anyActive) "Тулсет \"$name\" не активен." else "Нет активных тулсетов."
|
||||||
|
return DisableResult.NotActive(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Продлить таймаут (вызывается после успешного выполнения тулы набора). */
|
||||||
|
fun touch(sessionId: Long, name: String) {
|
||||||
|
synchronized(this) {
|
||||||
|
val session = active[sessionId] ?: return
|
||||||
|
if (session.containsKey(name)) session[name] = clock() + timeoutMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
/** Распознавание одной фразы (16 кГц mono PCM) в текст. */
|
||||||
|
interface PhraseRecognizer {
|
||||||
|
fun recognize(samples: FloatArray): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -23,16 +23,24 @@ object SttDebug {
|
|||||||
// через: adb push /data/local/tmp/... + run-as pkg cp
|
// через: adb push /data/local/tmp/... + run-as pkg cp
|
||||||
val modelsDir = File(context.filesDir, "models")
|
val modelsDir = File(context.filesDir, "models")
|
||||||
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
|
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
|
||||||
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath
|
val convFrontend = File(modelsDir, "conv_frontend.onnx").absolutePath
|
||||||
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath
|
val encoder = File(modelsDir, "encoder.int8.onnx").absolutePath
|
||||||
val tokens = File(modelsDir, "small-tokens.txt").absolutePath
|
val decoder = File(modelsDir, "decoder.int8.onnx").absolutePath
|
||||||
|
val tokens = File(modelsDir, "tokenizer").absolutePath
|
||||||
|
|
||||||
log("[stt] создаём VAD...")
|
log("[stt] создаём VAD...")
|
||||||
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
|
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
|
||||||
log("[stt] фраз (VAD): ${segments.size}")
|
log("[stt] фраз (VAD): ${segments.size}")
|
||||||
|
|
||||||
log("[stt] создаём Whisper-small (int8, CPU)...")
|
log("[stt] создаём Qwen3-ASR-0.6B (int8, CPU)...")
|
||||||
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4)
|
val stt = Qwen3AsrStt(
|
||||||
|
convFrontendPath = convFrontend,
|
||||||
|
encoderPath = encoder,
|
||||||
|
decoderPath = decoder,
|
||||||
|
tokenizerDir = tokens,
|
||||||
|
numThreads = 4,
|
||||||
|
hotwords = "тулсет,тулы,умеешь",
|
||||||
|
)
|
||||||
val parts = ArrayList<String>()
|
val parts = ArrayList<String>()
|
||||||
for ((i, seg) in segments.withIndex()) {
|
for ((i, seg) in segments.withIndex()) {
|
||||||
val startMs = seg.first / 16
|
val startMs = seg.first / 16
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package pw.binom.viewmate.phone.stt
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
|
import java.io.File
|
||||||
import com.k2fsa.sherpa.onnx.Vad
|
import java.io.FileOutputStream
|
||||||
import com.k2fsa.sherpa.onnx.VadModelConfig
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.Future
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import pw.binom.viewmate.phone.log
|
||||||
|
|
||||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||||
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||||
@@ -26,28 +26,180 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
|
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
|
||||||
* Whisper (в отдельном потоке) → колбэк [onPhrase].
|
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
|
||||||
|
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
|
||||||
|
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
|
||||||
|
*
|
||||||
|
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
|
||||||
|
* Чистая функция (без Android) — покрыта JVM-тестами.
|
||||||
|
*/
|
||||||
|
internal fun trimEdges(
|
||||||
|
samples: FloatArray,
|
||||||
|
threshold: Float = 0.01f,
|
||||||
|
windowSize: Int = 320,
|
||||||
|
padWindows: Int = 15,
|
||||||
|
): FloatArray {
|
||||||
|
if (samples.isEmpty()) return samples
|
||||||
|
var first = -1
|
||||||
|
var lastEnd = -1
|
||||||
|
var idx = 0
|
||||||
|
while (idx < samples.size) {
|
||||||
|
val end = minOf(idx + windowSize, samples.size)
|
||||||
|
var sum = 0.0
|
||||||
|
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
|
||||||
|
val rms = sqrt(sum / (end - idx)).toFloat()
|
||||||
|
if (rms >= threshold) {
|
||||||
|
if (first == -1) first = idx
|
||||||
|
lastEnd = end
|
||||||
|
}
|
||||||
|
idx = end
|
||||||
|
}
|
||||||
|
if (first == -1) return FloatArray(0)
|
||||||
|
val pad = padWindows * windowSize
|
||||||
|
val start = maxOf(0, first - pad)
|
||||||
|
val stop = minOf(samples.size, lastEnd + pad)
|
||||||
|
return samples.copyOfRange(start, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||||
|
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||||
|
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||||
|
* НЕ менять без перепроверки на тех же дампах.
|
||||||
|
*
|
||||||
|
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
|
||||||
|
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
|
||||||
|
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
|
||||||
|
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
|
||||||
|
* каждой стороны (clamp к границам массива). Фраза короче
|
||||||
|
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
|
||||||
|
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
|
||||||
|
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||||
|
* < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
|
||||||
|
*/
|
||||||
|
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> {
|
||||||
|
if (samples.isEmpty()) return emptyList()
|
||||||
|
val winCount = (samples.size + windowSize - 1) / windowSize
|
||||||
|
val isSound = BooleanArray(winCount)
|
||||||
|
var anySound = false
|
||||||
|
for (w in 0 until winCount) {
|
||||||
|
val s = w * windowSize
|
||||||
|
val e = minOf(s + windowSize, samples.size)
|
||||||
|
var sum = 0.0
|
||||||
|
for (k in s until e) sum += samples[k] * samples[k].toDouble()
|
||||||
|
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
|
||||||
|
isSound[w] = true
|
||||||
|
anySound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anySound) return emptyList()
|
||||||
|
|
||||||
|
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
|
||||||
|
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
|
||||||
|
class Phrase(var start: Int, var end: Int)
|
||||||
|
|
||||||
|
val phrases = mutableListOf<Phrase>()
|
||||||
|
var pos = 0
|
||||||
|
while (pos < winCount) {
|
||||||
|
var sil = 0
|
||||||
|
while (pos + sil < winCount && !isSound[pos + sil]) sil++
|
||||||
|
if (pos + sil >= winCount) break
|
||||||
|
val start = pos + sil
|
||||||
|
var end = start
|
||||||
|
var continuePhrase = true
|
||||||
|
while (continuePhrase) {
|
||||||
|
var run = 0
|
||||||
|
while (end + run < winCount && isSound[end + run]) run++
|
||||||
|
end += run
|
||||||
|
if (end >= winCount) { continuePhrase = false; break }
|
||||||
|
var sil2 = 0
|
||||||
|
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
|
||||||
|
if (sil2 >= minSilenceWindows) {
|
||||||
|
continuePhrase = false
|
||||||
|
} else {
|
||||||
|
end += sil2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phrases.add(Phrase(start, end))
|
||||||
|
pos = end
|
||||||
|
}
|
||||||
|
|
||||||
|
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
|
||||||
|
while (phrases.size > 1) {
|
||||||
|
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
|
||||||
|
if (idx < 0) break
|
||||||
|
val other = if (idx == 0) idx + 1 else idx - 1
|
||||||
|
val lo = minOf(idx, other)
|
||||||
|
val hi = maxOf(idx, other)
|
||||||
|
val merged = Phrase(
|
||||||
|
minOf(phrases[lo].start, phrases[hi].start),
|
||||||
|
maxOf(phrases[lo].end, phrases[hi].end),
|
||||||
|
)
|
||||||
|
phrases.removeAt(hi)
|
||||||
|
phrases.removeAt(lo)
|
||||||
|
phrases.add(lo, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pad = padWindows * windowSize
|
||||||
|
val out = mutableListOf<FloatArray>()
|
||||||
|
for (p in phrases) {
|
||||||
|
val s = maxOf(0, p.start * windowSize - pad)
|
||||||
|
val e = minOf(samples.size, p.end * windowSize + pad)
|
||||||
|
if (e - s <= maxPhraseSamples) {
|
||||||
|
out += samples.copyOfRange(s, e)
|
||||||
|
} else {
|
||||||
|
val chunk = (maxPhraseSamples / windowSize) * windowSize
|
||||||
|
var cs = s
|
||||||
|
while (cs < e) {
|
||||||
|
val ce = minOf(e, cs + chunk)
|
||||||
|
out += samples.copyOfRange(cs, ce)
|
||||||
|
cs = ce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||||
|
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||||
|
* [PhraseRecognizer] с предварительным обрезанием краевой тишины ([trimEdges]).
|
||||||
*
|
*
|
||||||
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
|
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
|
||||||
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
|
* идёт в однопоточном executor-е (не блокирует приём аудио).
|
||||||
*
|
*
|
||||||
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s]
|
* Тишина [SILENCE_TIMEOUT_MS] (с момента первого [accept]) → автo-финиш:
|
||||||
* (автo-отмена, вызывается один раз за сессию).
|
* накопленный буфер распознаётся (как [finish]) и результат отдаётся в
|
||||||
|
* [onAutoFinished], затем сессия сбрасывается и вызывается [onSilence30s]
|
||||||
|
* (автo-отмена, один раз за сессию).
|
||||||
*/
|
*/
|
||||||
class SttStreamer(
|
class SttStreamer(
|
||||||
private val stt: WhisperStt,
|
private val stt: PhraseRecognizer,
|
||||||
private val vadModelPath: String,
|
|
||||||
private val onPhrase: (phrase: String, full: String) -> Unit,
|
|
||||||
private val onSilence30s: () -> Unit,
|
private val onSilence30s: () -> Unit,
|
||||||
|
/** Полный текст сессии, распознанный автo-финишем по тишине (см. [onSilence30s]). */
|
||||||
|
private val onAutoFinished: (full: String) -> Unit,
|
||||||
|
private val dumpPcm: File? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val SILENCE_TIMEOUT_MS = 30_000L
|
private const val SILENCE_TIMEOUT_MS = 30_000L
|
||||||
private const val WINDOW_SIZE = 512
|
|
||||||
|
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
|
||||||
|
private const val SAMPLE_RATE = 16_000
|
||||||
|
|
||||||
|
/** Максимум PCM для распознавания: 120 с (дольше — последние 120 с). */
|
||||||
|
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 120
|
||||||
}
|
}
|
||||||
|
|
||||||
private val lock = java.util.concurrent.locks.ReentrantLock()
|
private val lock = java.util.concurrent.locks.ReentrantLock()
|
||||||
private val vad: Vad = Vad(null, vadConfig())
|
|
||||||
private val executor = Executors.newSingleThreadExecutor { r ->
|
private val executor = Executors.newSingleThreadExecutor { r ->
|
||||||
Thread(r, "stt-recognize").apply { isDaemon = true }
|
Thread(r, "stt-recognize").apply { isDaemon = true }
|
||||||
}
|
}
|
||||||
@@ -64,22 +216,40 @@ class SttStreamer(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var deadlineMs = 0L
|
private var deadlineMs = 0L
|
||||||
|
|
||||||
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
/**
|
||||||
private var pending = FloatArray(0)
|
* Debug-дампа принятых PCM: сырые s16le (16 кГц, mono), без WAV-заголовка —
|
||||||
|
* формат помечен в логе. Сбой открытия не ломает распознавание.
|
||||||
|
*/
|
||||||
|
private val dumpOut: FileOutputStream? = dumpPcm?.let { file ->
|
||||||
|
runCatching {
|
||||||
|
FileOutputStream(file, true).also {
|
||||||
|
log("stt", "PCM-дампа: ${file.absolutePath} (16 кГц, s16, mono)")
|
||||||
|
}
|
||||||
|
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Весь PCM сессии (FloatArray @16 кГц): [finish] прогоняет его через распознаватель одним вызовом. */
|
||||||
|
private var sessionPcm: FloatArray = FloatArray(0)
|
||||||
|
|
||||||
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
|
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
|
||||||
private var lastFuture: Future<*>? = null
|
private var lastFuture: java.util.concurrent.Future<*>? = null
|
||||||
|
|
||||||
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
|
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
|
||||||
while (true) {
|
while (true) {
|
||||||
val deadline = deadlineMs
|
val deadline = deadlineMs
|
||||||
if (deadline > 0 && System.currentTimeMillis() > deadline) {
|
if (deadline > 0 && System.currentTimeMillis() > deadline) {
|
||||||
lock.withLock {
|
// Снимаем срок и освобождаем lock до распознавания — watchdog не держит
|
||||||
if (deadlineMs == deadline) {
|
// lock при вызове finish (иначе блокировка-в-блокировке c accept).
|
||||||
|
val expired = lock.withLock {
|
||||||
|
if (deadlineMs != deadline) return@withLock false
|
||||||
deadlineMs = 0
|
deadlineMs = 0
|
||||||
resetLocked()
|
true
|
||||||
onSilence30s()
|
|
||||||
}
|
}
|
||||||
|
if (expired) {
|
||||||
|
val fullText = finish()
|
||||||
|
if (fullText.isNotBlank()) onAutoFinished(fullText)
|
||||||
|
lock.withLock { resetLocked() }
|
||||||
|
onSilence30s()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
|
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
|
||||||
@@ -91,110 +261,92 @@ class SttStreamer(
|
|||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
||||||
val floats = s16leToFloat(pcm)
|
val floats = s16leToFloat(pcm)
|
||||||
var i = 0
|
sessionPcm = sessionPcm + floats
|
||||||
|
|
||||||
if (pending.isNotEmpty()) {
|
|
||||||
val need = WINDOW_SIZE - pending.size
|
|
||||||
val take = minOf(need, floats.size)
|
|
||||||
val combined = pending + floats.copyOfRange(0, take)
|
|
||||||
i += take
|
|
||||||
if (combined.size == WINDOW_SIZE) {
|
|
||||||
feedWindow(combined)
|
|
||||||
pending = FloatArray(0)
|
|
||||||
} else {
|
|
||||||
pending = combined
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
val out = dumpOut
|
||||||
|
if (out != null) {
|
||||||
while (i + WINDOW_SIZE <= floats.size) {
|
runCatching { out.write(pcm) }
|
||||||
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE))
|
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||||
i += WINDOW_SIZE
|
|
||||||
}
|
|
||||||
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
|
* Завершить сессию (клик): распознать весь буфер сессии одним вызовом
|
||||||
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после
|
* (хвост [MAX_FULL_BUFFER_SAMPLES], если длиннее) с обрезанием краевой тишины
|
||||||
* вызова нужно [reset] (состояние сессии снимает вызывающий).
|
* — [full] перезаписывается результатом. Вернуть полный текст.
|
||||||
|
* Буферы НЕ сбрасывает — после вызова нужен [reset] (состояние сессии
|
||||||
|
* снимает вызывающий).
|
||||||
*/
|
*/
|
||||||
fun finish(): String {
|
fun finish(): String {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
vad.flush()
|
recognizeFullBufferLocked()
|
||||||
while (!vad.empty()) {
|
|
||||||
val seg = vad.front()
|
|
||||||
recognizeSegment(seg.samples)
|
|
||||||
vad.pop()
|
|
||||||
}
|
|
||||||
val last = lastFuture
|
|
||||||
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
|
|
||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Распознать весь буфер сессии [sessionPcm]: целиком, если ≤
|
||||||
|
* [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] сэмплов
|
||||||
|
* (команда перед кликом точно влезает). Перед распознаванием применяется
|
||||||
|
* [trimEdges], затем [splitPhrases] режет буфер на фразы по паузам — каждая
|
||||||
|
* фраза распознаётся отдельно (whisper принимает волны только ≤ 30 с),
|
||||||
|
* непустые результаты склеиваются через пробел и перезаписывают [full].
|
||||||
|
* Все фразы мусорные → [full] НЕ трогается. Вызывать с удержанным [lock].
|
||||||
|
*/
|
||||||
|
private fun recognizeFullBufferLocked() {
|
||||||
|
val buffer = sessionPcm
|
||||||
|
if (buffer.isEmpty()) return
|
||||||
|
val base = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
|
||||||
|
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
|
||||||
|
} else buffer
|
||||||
|
val chunk = trimEdges(base)
|
||||||
|
if (chunk.isEmpty()) return
|
||||||
|
val phrases = splitPhrases(chunk)
|
||||||
|
if (phrases.isEmpty()) return
|
||||||
|
val sessionId = session
|
||||||
|
val sizes = phrases.map { "%.1fс".format(it.size / SAMPLE_RATE.toFloat()) }.joinToString(", ")
|
||||||
|
val task = executor.submit {
|
||||||
|
if (sessionId != session) return@submit
|
||||||
|
runCatching {
|
||||||
|
val parts = ArrayList<String>()
|
||||||
|
for (ph in phrases) {
|
||||||
|
val text = stt.recognize(ph).trim()
|
||||||
|
if (text.isNotBlank()) parts.add(text)
|
||||||
|
}
|
||||||
|
if (parts.isNotEmpty()) full = parts.joinToString(" ")
|
||||||
|
parts
|
||||||
|
}.onSuccess { parts ->
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) — без текста (мусор, full не меняю)")
|
||||||
|
} else {
|
||||||
|
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) → '$full'")
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
log("stt", "полный буфер: ошибка распознавания: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastFuture = task
|
||||||
|
runCatching { task.get(60, TimeUnit.SECONDS) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
lock.withLock { resetLocked() }
|
lock.withLock { resetLocked() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */
|
/** Освободить ресурсы и сторожевой поток (при выключении приложения). */
|
||||||
fun close() {
|
fun close() {
|
||||||
lock.withLock {
|
lock.withLock { deadlineMs = 0 }
|
||||||
deadlineMs = 0
|
runCatching { dumpOut?.close() }
|
||||||
runCatching { vad.release() }
|
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||||
}
|
|
||||||
executor.shutdownNow()
|
executor.shutdownNow()
|
||||||
watchdog.interrupt()
|
watchdog.interrupt()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetLocked() {
|
private fun resetLocked() {
|
||||||
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
|
|
||||||
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
|
|
||||||
runCatching { vad.flush() }
|
|
||||||
while (!vad.empty()) vad.pop()
|
|
||||||
session++
|
session++
|
||||||
full = ""
|
full = ""
|
||||||
pending = FloatArray(0)
|
sessionPcm = FloatArray(0)
|
||||||
deadlineMs = 0
|
deadlineMs = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
|
|
||||||
private fun feedWindow(window: FloatArray) {
|
|
||||||
vad.acceptWaveform(window)
|
|
||||||
while (!vad.empty()) {
|
|
||||||
val seg = vad.front()
|
|
||||||
// Копия: массив из нативного VAD может переиспользоваться после pop.
|
|
||||||
recognizeSegment(seg.samples.copyOf())
|
|
||||||
vad.pop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Распознать сегмент в executor-е: обновить full и позвать onPhrase. */
|
|
||||||
private fun recognizeSegment(samples: FloatArray) {
|
|
||||||
val sessionId = session
|
|
||||||
lastFuture = executor.submit {
|
|
||||||
if (sessionId != session) return@submit
|
|
||||||
val phrase = runCatching { stt.recognize(samples) }.getOrDefault("")
|
|
||||||
if (phrase.isBlank()) return@submit
|
|
||||||
val newFull = if (full.isEmpty()) phrase else "$full $phrase"
|
|
||||||
full = newFull
|
|
||||||
deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
|
||||||
onPhrase(phrase, newFull)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun vadConfig(): VadModelConfig = VadModelConfig(
|
|
||||||
sileroVadModelConfig = SileroVadModelConfig(
|
|
||||||
model = vadModelPath,
|
|
||||||
threshold = 0.5f,
|
|
||||||
minSilenceDuration = 0.25f,
|
|
||||||
minSpeechDuration = 0.25f,
|
|
||||||
windowSize = WINDOW_SIZE,
|
|
||||||
maxSpeechDuration = 20f,
|
|
||||||
),
|
|
||||||
sampleRate = 16000,
|
|
||||||
numThreads = 1,
|
|
||||||
provider = "cpu",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class WhisperStt(
|
|||||||
private val tokensPath: String,
|
private val tokensPath: String,
|
||||||
private val numThreads: Int = 4,
|
private val numThreads: Int = 4,
|
||||||
private val language: String = "ru",
|
private val language: String = "ru",
|
||||||
) {
|
) : PhraseRecognizer {
|
||||||
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||||
null,
|
null,
|
||||||
OfflineRecognizerConfig(
|
OfflineRecognizerConfig(
|
||||||
@@ -33,7 +33,7 @@ class WhisperStt(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||||
fun recognize(samples: FloatArray): String {
|
override fun recognize(samples: FloatArray): String {
|
||||||
val stream = recognizer.createStream()
|
val stream = recognizer.createStream()
|
||||||
try {
|
try {
|
||||||
stream.acceptWaveform(samples, 16000)
|
stream.acceptWaveform(samples, 16000)
|
||||||
|
|||||||
@@ -1,35 +1,241 @@
|
|||||||
package pw.binom.viewmate.phone.ui
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.agent.ChatRole
|
||||||
|
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||||
|
import pw.binom.viewmate.phone.agent.Session
|
||||||
|
|
||||||
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */
|
/**
|
||||||
|
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
|
||||||
|
* локального ассистента (та же очередь, что и голос с очков). Управление
|
||||||
|
* сессиями: новая, переключение (выпадающий список), сброс текущей.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen() {
|
fun ChatScreen() {
|
||||||
|
val app = PhoneApp.instance
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
|
||||||
|
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) }
|
||||||
|
var input by remember { mutableStateOf("") }
|
||||||
|
var sessionMenu by remember { mutableStateOf(false) }
|
||||||
|
var activeId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Загрузка списка сессий и сообщений при старте и при смене активной.
|
||||||
|
LaunchedEffect(activeId) {
|
||||||
|
val dao = app.chatDao
|
||||||
|
val (sess, msgs) = withContext(Dispatchers.IO) {
|
||||||
|
val all = dao.listSessions()
|
||||||
|
val target = activeId ?: all.firstOrNull()?.id
|
||||||
|
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
|
||||||
|
all to history.map { it.role to it.content }
|
||||||
|
}
|
||||||
|
sessions = sess
|
||||||
|
activeId = activeId ?: sess.firstOrNull()?.id
|
||||||
|
messages = msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписка на смену активной сессии извне (кнопки в PhoneApp).
|
||||||
|
val externalActive by app.activeSessionId.collectAsState()
|
||||||
|
LaunchedEffect(externalActive) {
|
||||||
|
if (externalActive != null && externalActive != activeId) {
|
||||||
|
activeId = externalActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автопрокрутка вниз при новых сообщениях.
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Периодическая перезагрузка сообщений из БД (ответ ассистента приходит
|
||||||
|
// асинхронно — лента должна подхватывать его без ручного обновления)
|
||||||
|
// + списка сессий (автоименование: имя проставляется при первом сообщении).
|
||||||
|
LaunchedEffect(activeId) {
|
||||||
|
while (true) {
|
||||||
|
delay(2_000)
|
||||||
|
val id = activeId
|
||||||
|
if (id != null) {
|
||||||
|
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } }
|
||||||
|
if (fresh != messages) messages = fresh
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
modifier = Modifier
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.fillMaxSize()
|
||||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
.imePadding()
|
||||||
|
.padding(12.dp),
|
||||||
) {
|
) {
|
||||||
|
// Шапка: название + управление сессиями
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = "Чат с Порфирием",
|
text = "Порфирий",
|
||||||
color = PhoneText,
|
color = PhoneText,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
// Переключение сессий
|
||||||
|
Box {
|
||||||
|
TextButton(onClick = { sessionMenu = true }) {
|
||||||
Text(
|
Text(
|
||||||
text = "в разработке (следующий этап)",
|
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
|
||||||
|
color = PhoneAccent,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
|
||||||
|
sessions.forEach { s ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(s.title, fontSize = 12.sp) },
|
||||||
|
onClick = {
|
||||||
|
activeId = s.id
|
||||||
|
app.selectChatSession(s.id)
|
||||||
|
sessionMenu = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) },
|
||||||
|
onClick = {
|
||||||
|
activeId = app.newChatSession()
|
||||||
|
sessionMenu = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Сброс текущей
|
||||||
|
TextButton(onClick = {
|
||||||
|
app.resetChatSession()
|
||||||
|
activeId?.let { id ->
|
||||||
|
val history = app.chatDao.messagesForSession(id).map { it.role to it.content }
|
||||||
|
messages = history
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
|
||||||
|
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
|
||||||
|
val llmChoice by app.llmChoice.collectAsState()
|
||||||
|
Text(
|
||||||
|
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Лента сообщений
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
|
||||||
color = PhoneMuted,
|
color = PhoneMuted,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(messages) { (role, content) ->
|
||||||
|
MessageBubble(role = role, text = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Ввод
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text("Сообщение…", fontSize = 14.sp, color = PhoneMuted) },
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium.copy(color = PhoneText),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val text = input.trim()
|
||||||
|
if (text.isEmpty()) return@Button
|
||||||
|
input = ""
|
||||||
|
messages = messages + (ChatRole.USER to text)
|
||||||
|
app.sendToAssistant(text)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("➤", fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пузырь сообщения: user — справа акцентный, assistant — слева карточка. */
|
||||||
|
@Composable
|
||||||
|
private fun MessageBubble(role: ChatRole, text: String) {
|
||||||
|
val isUser = role == ChatRole.USER
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
|
||||||
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = PhoneText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
.background(
|
||||||
|
color = if (isUser) PhoneSurface else PhoneCard,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -24,6 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import pw.binom.viewmate.phone.PhoneApp
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
||||||
|
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||||
|
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
|
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
|
||||||
@@ -137,6 +141,10 @@ fun GlassesInfoScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
LlmModelSection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,3 +173,118 @@ private fun InfoRow(label: String, value: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Блок выбора модели LLM ассистента: серверная (llm.binom.pw) или локальная
|
||||||
|
* Gemma 4 E2B (LiteRT-LM) + управление скачиванием модели (прогресс, отмена).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LlmModelSection() {
|
||||||
|
val app = PhoneApp.instance
|
||||||
|
val choice by app.llmChoice.collectAsState()
|
||||||
|
val llmState by app.localLlmState.collectAsState()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||||
|
SectionLabel("Модель LLM ассистента")
|
||||||
|
StatusBlock {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
LlmChoiceChip(
|
||||||
|
label = "сервер",
|
||||||
|
selected = choice == LlmModelChoice.REMOTE,
|
||||||
|
onClick = { app.setLlmModelChoice(LlmModelChoice.REMOTE) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
LlmChoiceChip(
|
||||||
|
label = "локальная",
|
||||||
|
selected = choice == LlmModelChoice.LOCAL,
|
||||||
|
onClick = { app.setLlmModelChoice(LlmModelChoice.LOCAL) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
if (choice == LlmModelChoice.LOCAL) {
|
||||||
|
when (val s = llmState) {
|
||||||
|
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ProgressBar(
|
||||||
|
percent = s.percent,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Скачивание модели… ${s.percent}%",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalLlmModelState.Ready -> Text(
|
||||||
|
text = "Модель готова (Gemma 4 E2B, локально)",
|
||||||
|
color = PhoneGreen,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Ошибка скачивания: ${s.message}",
|
||||||
|
color = PhoneRed,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Чип переключателя выбора модели (активный — акцентный фон). */
|
||||||
|
@Composable
|
||||||
|
private fun LlmChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(if (selected) PhoneAccent else PhoneSurface)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = if (selected) PhoneBackground else PhoneTextDim,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маленькая текстовая кнопка (скачать/отмена/повтор) в блоке LLM. */
|
||||||
|
@Composable
|
||||||
|
private fun LlmSmallAction(label: String, onClick: () -> Unit) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = PhoneAccent,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ fun GlassesScreen(
|
|||||||
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
|
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
|
||||||
var scale by remember { mutableStateOf(app.state.scale) }
|
var scale by remember { mutableStateOf(app.state.scale) }
|
||||||
|
|
||||||
|
// высота экрана очков (0..1): 1 — у верха, 0 — нижняя кромка у низа
|
||||||
|
var vertical by remember { mutableStateOf(app.state.vertical) }
|
||||||
|
|
||||||
val actions = app.actions
|
val actions = app.actions
|
||||||
|
|
||||||
// активное скачивание на очки: первое с незавершённым файлом
|
// активное скачивание на очки: первое с незавершённым файлом
|
||||||
@@ -183,6 +186,31 @@ fun GlassesScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// высота экрана очков: 1.0 — у верха (как сейчас), 0.0 — нижняя кромка у низа (с учётом масштаба)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Высота: ${"%.2f".format(vertical)}",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = vertical,
|
||||||
|
onValueChange = { vertical = it },
|
||||||
|
onValueChangeFinished = {
|
||||||
|
app.state.vertical = vertical
|
||||||
|
val a = actions
|
||||||
|
if (a != null) scope.launch { a.applyVertical(vertical) }
|
||||||
|
},
|
||||||
|
valueRange = 0f..1f,
|
||||||
|
steps = 9, // шаг 0.1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// активная загрузка контента на очки
|
// активная загрузка контента на очки
|
||||||
if (activeGlassesItem != null) {
|
if (activeGlassesItem != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -360,6 +388,9 @@ private fun GlassesStatusCard(
|
|||||||
fg = if (playing) PhoneBlueText else PhoneMuted,
|
fg = if (playing) PhoneBlueText else PhoneMuted,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Полевое испытание либы rayneo-mercury (BLE-протокол очков)
|
||||||
|
MercuryPanel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.viewmate.phone.MercuryBridge
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
|
||||||
|
* Минимум: найти очки → подключить → погасить/включить экран (deepSuspend).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MercuryPanel() {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val state by MercuryBridge.state.collectAsState()
|
||||||
|
val busy = false
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(PhoneCard)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🛰 Mercury BLE (полевой тест)",
|
||||||
|
color = PhoneText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.statusText,
|
||||||
|
color = if (state.lastError != null) PhoneRedText else PhoneMuted,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
if (state.connected) {
|
||||||
|
Text(
|
||||||
|
text = "очки: ${state.glassesName ?: "?"} · BLE готов",
|
||||||
|
color = PhoneBlueText,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.padding(top = 10.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
MercuryBridge.connectFirst()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PhoneBlue,
|
||||||
|
contentColor = PhoneBlueText,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("подключить очки", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch { MercuryBridge.disconnect() }
|
||||||
|
},
|
||||||
|
enabled = state.connected,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PhoneSurface,
|
||||||
|
contentColor = PhoneMuted,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("отключить", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch { MercuryBridge.screenOff() }
|
||||||
|
},
|
||||||
|
enabled = state.connected,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF2B2F36),
|
||||||
|
contentColor = Color(0xFF9AA0A6),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("🌙 погасить экран", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch { MercuryBridge.screenOn() }
|
||||||
|
},
|
||||||
|
enabled = state.connected,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PhoneAccent,
|
||||||
|
contentColor = Color(0xFF10251A),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("☀️ включить экран", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
|
|||||||
|
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
import io.ktor.client.plugins.websocket.webSocket
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
import io.ktor.server.testing.testApplication
|
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
import io.ktor.websocket.send
|
import io.ktor.websocket.send
|
||||||
@@ -23,24 +22,49 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
|
|||||||
import pw.binom.viewmate.core.protocol.Hello
|
import pw.binom.viewmate.core.protocol.Hello
|
||||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||||
import pw.binom.viewmate.core.protocol.SetMode
|
import pw.binom.viewmate.core.protocol.SetMode
|
||||||
|
import pw.binom.viewmate.core.protocol.StopStt
|
||||||
|
import pw.binom.viewmate.core.protocol.SttAudio
|
||||||
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
|
import pw.binom.viewmate.core.protocol.SttDone
|
||||||
import pw.binom.viewmate.core.protocol.Welcome
|
import pw.binom.viewmate.core.protocol.Welcome
|
||||||
import pw.binom.viewmate.core.protocol.protocolJson
|
import pw.binom.viewmate.core.protocol.protocolJson
|
||||||
|
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
|
||||||
|
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||||
|
import pw.binom.viewmate.phone.stt.WavPcm
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
import kotlin.test.fail
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
|
||||||
|
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
|
||||||
|
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
|
||||||
|
*/
|
||||||
class GlassesServerTest {
|
class GlassesServerTest {
|
||||||
|
|
||||||
private val json = protocolJson
|
private val json = protocolJson
|
||||||
|
|
||||||
@Test
|
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
|
||||||
fun helloGetsWelcome() = testApplication {
|
private fun startServer(): Pair<GlassesServer, Int> {
|
||||||
val hub = GlassesHub(PhoneState())
|
val port = freePort()
|
||||||
application { glassesServerModule(hub) }
|
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
|
||||||
val client = createClient { install(WebSockets) }
|
server.start()
|
||||||
|
return server to port
|
||||||
|
}
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
private fun freePort(): Int {
|
||||||
|
val s = java.net.ServerSocket(0)
|
||||||
|
val p = s.localPort
|
||||||
|
s.close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun helloGetsWelcome() = runServer { port, hub ->
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
||||||
val frame = incoming.receive() as Frame.Text
|
val frame = incoming.receive() as Frame.Text
|
||||||
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
@@ -50,12 +74,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun gestureIsProcessed() = testApplication {
|
fun gestureIsProcessed() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
||||||
}
|
}
|
||||||
@@ -64,28 +85,22 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun connectedCounterTracksSessions() = testApplication {
|
fun connectedCounterTracksSessions() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) { }
|
|
||||||
awaitTrue { hub.connected.value == 0 }
|
awaitTrue { hub.connected.value == 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun broadcastReachesAllGlasses() = testApplication {
|
fun broadcastReachesAllGlasses() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
val opened = Channel<Unit>(2)
|
val opened = Channel<Unit>(2)
|
||||||
val received = Channel<HostToGlasses>(2)
|
val received = Channel<HostToGlasses>(2)
|
||||||
|
|
||||||
val jobs = (1..2).map {
|
val jobs = (1..2).map {
|
||||||
launch {
|
launch {
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
opened.send(Unit)
|
opened.send(Unit)
|
||||||
while (true) {
|
while (true) {
|
||||||
val frame = incoming.receive()
|
val frame = incoming.receive()
|
||||||
@@ -115,12 +130,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
|
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
|
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
|
||||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
|
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
|
||||||
}
|
}
|
||||||
@@ -131,12 +143,9 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
|
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
|
||||||
val hub = GlassesHub(PhoneState())
|
val client = wsClient()
|
||||||
application { glassesServerModule(hub) }
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
val client = createClient { install(WebSockets) }
|
|
||||||
|
|
||||||
client.webSocket(GLASSES_WS_PATH) {
|
|
||||||
send(
|
send(
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
GlassesToHost.serializer(),
|
GlassesToHost.serializer(),
|
||||||
@@ -163,6 +172,81 @@ class GlassesServerTest {
|
|||||||
assertEquals("0.2.0", hub.appVersion.value)
|
assertEquals("0.2.0", hub.appVersion.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- StopStt → SttCancel/SttDone (TASK-cancel-listen.md) ----------
|
||||||
|
|
||||||
|
/** StopStt(cancel=true) — двойной тап-отмена на очках: очки получают SttCancel(reason=cancel). */
|
||||||
|
@Test
|
||||||
|
fun stopSttCancelSendsSttCancelReasonCancel() = runServer { port, hub ->
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = true)))
|
||||||
|
val frame = incoming.receive() as Frame.Text
|
||||||
|
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
|
val cancel = assertIs<SttCancel>(msg)
|
||||||
|
assertEquals(SttCancel.REASON_CANCEL, cancel.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** StopStt(cancel=false) с пустым текстом — очки возвращаются в «слушаю»: SttCancel(reason=user). */
|
||||||
|
@Test
|
||||||
|
fun stopSttEmptyTextBroadcastsSttCancelReasonUser() = runServer { port, hub ->
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
|
||||||
|
val frame = incoming.receive() as Frame.Text
|
||||||
|
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
|
val cancel = assertIs<SttCancel>(msg)
|
||||||
|
assertEquals(SttCancel.REASON_USER, cancel.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): весь принятый PCM
|
||||||
|
* распознаётся одним вызовом на буфер сессии, фейк-распознаватель
|
||||||
|
* даёт «привет» → очки получают SttDone с непустым текстом.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
|
||||||
|
hub.sttFactory = {
|
||||||
|
SttStreamer(
|
||||||
|
stt = FakePhraseRecognizer(),
|
||||||
|
onSilence30s = { },
|
||||||
|
onAutoFinished = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
|
// кидать по 3200 байт (100 мс @ 16 кГц s16le mono)
|
||||||
|
var off = 0
|
||||||
|
while (off < pcm.size) {
|
||||||
|
val end = minOf(off + 3200, pcm.size)
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end))))
|
||||||
|
off = end
|
||||||
|
}
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
|
||||||
|
val frame = incoming.receive() as Frame.Text
|
||||||
|
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
|
val done = assertIs<SttDone>(msg)
|
||||||
|
assertTrue(done.full.isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- помощники ----------
|
||||||
|
|
||||||
|
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
|
||||||
|
val (server, port) = startServer()
|
||||||
|
try {
|
||||||
|
kotlinx.coroutines.runBlocking { block(port, server.hub) }
|
||||||
|
} finally {
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wsClient() = io.ktor.client.HttpClient {
|
||||||
|
install(WebSockets)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||||
val deadline = System.currentTimeMillis() + timeoutMs
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
while (!condition()) {
|
while (!condition()) {
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Механика агента: три состояния промпта, бесшовная подмена, валидация
|
||||||
|
* аргументов, невидимые тулы, таймаут (инжект часов), по-сессийность.
|
||||||
|
*/
|
||||||
|
class AgentToolkitTest {
|
||||||
|
|
||||||
|
private var now = 10_000_000L
|
||||||
|
|
||||||
|
private class Fixture(val toolkit: AgentToolkit, val calls: MutableList<String>, val shown: MutableList<String>)
|
||||||
|
|
||||||
|
private fun fixture(withExecutor: Boolean = true): Fixture {
|
||||||
|
now = 10_000_000L
|
||||||
|
val calls = mutableListOf<String>()
|
||||||
|
val shown = mutableListOf<String>()
|
||||||
|
val registry = ToolsetRegistry(timeoutMs = ToolsetRegistry.DEFAULT_TIMEOUT_MS, clock = { now })
|
||||||
|
val ts = Toolset(
|
||||||
|
name = "media",
|
||||||
|
description = "Управление медиа и воспроизведением (search/info/play)",
|
||||||
|
tools = listOf(
|
||||||
|
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
|
||||||
|
ToolSignature("info", "Детали фильма.", args = listOf("id")),
|
||||||
|
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (withExecutor) {
|
||||||
|
registry.register(ts) { tset, tool, args ->
|
||||||
|
calls.add("$tset.$tool ${args.entries.joinToString { "${'{' }${it.key}=${it.value}${'}'}" }}")
|
||||||
|
"Результат $tool: ${args.entries.joinToString { "${it.key}=${it.value}" }}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
registry.register(ts, null)
|
||||||
|
}
|
||||||
|
val skills = SkillRegistry(
|
||||||
|
listOf(
|
||||||
|
Skill("media", "Поиск фильмов, детали, запуск/управление.", "ТЕЛО: media_search|query=..."),
|
||||||
|
Skill("web", "Как сделать веб-поиск.", "Шаги веб-поиска..."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Fixture(
|
||||||
|
toolkit = AgentToolkit(skills = skills, toolsets = registry, showText = { shown.add(it) }),
|
||||||
|
calls = calls,
|
||||||
|
shown = shown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(d: AgentToolkit.Dispatch): String =
|
||||||
|
(d as AgentToolkit.Dispatch.Run).result
|
||||||
|
|
||||||
|
// ---------- три состояния промпта ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `состояние 1 нет активных тулсетов, каталог и скиллы`() {
|
||||||
|
val p = fixture().toolkit.systemPrompt(1)
|
||||||
|
assertTrue(p.contains("Ядро:"))
|
||||||
|
assertTrue(p.contains("get_current_time"))
|
||||||
|
assertTrue(p.contains("show_text|text"))
|
||||||
|
assertTrue(p.contains("enable_toolset|имя"))
|
||||||
|
assertTrue(p.contains("Каталог тулсетов"))
|
||||||
|
assertTrue(p.contains("media — Управление медиа и воспроизведением"))
|
||||||
|
// скиллы: по строке на каждый
|
||||||
|
assertTrue(p.contains("media — Поиск фильмов, детали, запуск/управление."))
|
||||||
|
assertTrue(p.contains("web — Как сделать веб-поиск."))
|
||||||
|
// описания тул набора ещё не раскрыты (набор не активен)
|
||||||
|
assertFalse(p.contains("Активные тулсеты"))
|
||||||
|
assertFalse(p.contains("media_search|query"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enable невидимый, промпт пересобирается, состояние 3`() {
|
||||||
|
val f = fixture()
|
||||||
|
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
assertTrue(d is AgentToolkit.Dispatch.Silent)
|
||||||
|
val p = f.toolkit.systemPrompt(1)
|
||||||
|
assertTrue(p.contains("Активные тулсеты: media"))
|
||||||
|
assertTrue(p.contains("media_search|query — Поиск фильмов"))
|
||||||
|
assertTrue(p.contains("media_play|id,seek_ms? — Запуск/управление"))
|
||||||
|
// связанный скилл убран из промпта (имя скилла = имя тулсета)
|
||||||
|
assertFalse(p.contains("media — Поиск фильмов"))
|
||||||
|
// остальные скиллы на месте
|
||||||
|
assertTrue(p.contains("web — Как сделать веб-поиск."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `повторный enable, напоминание, уже активен`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
val d2 = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
|
||||||
|
val p1 = f.toolkit.systemPrompt(1)
|
||||||
|
assertTrue(p1.contains("Тулсет \"media\" уже активен, таймаут продлён"))
|
||||||
|
// напоминание одноразовое: следующий промпт без него
|
||||||
|
val p2 = f.toolkit.systemPrompt(1)
|
||||||
|
assertFalse(p2.contains("уже активен"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disable обратно в состояние 1, двойной disable не ошибка`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
|
||||||
|
val p1 = f.toolkit.systemPrompt(1)
|
||||||
|
assertFalse(p1.contains("Активные тулсеты"))
|
||||||
|
assertTrue(p1.contains("media — Поиск фильмов")) // скилл вернулся в индекс
|
||||||
|
val d2 = f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
|
||||||
|
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
|
||||||
|
val p2 = f.toolkit.systemPrompt(1)
|
||||||
|
assertTrue(p2.contains("Нет активных тулсетов"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `неизвестный набор, напоминание с доступными в промпте`() {
|
||||||
|
val f = fixture()
|
||||||
|
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "other"))
|
||||||
|
assertTrue(d is AgentToolkit.Dispatch.Silent)
|
||||||
|
val p = f.toolkit.systemPrompt(1)
|
||||||
|
assertTrue(p.contains("Набора \"other\" нет"))
|
||||||
|
assertTrue(p.contains("media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- бесшовная подмена ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `подмена, аргументы не совпадают, вызов явно не выполнялся`() {
|
||||||
|
val f = fixture()
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
|
||||||
|
assertTrue(msg.contains("Тулсет media активирован"))
|
||||||
|
assertTrue(msg.contains("Реестр:"))
|
||||||
|
assertTrue(msg.contains("media_search"))
|
||||||
|
assertTrue(msg.contains("ещё НЕ выполнялся"))
|
||||||
|
assertTrue(msg.contains("вызови media_search с аргументами: query"))
|
||||||
|
// тул не выполнялся реально
|
||||||
|
assertTrue(f.calls.isEmpty())
|
||||||
|
// набор стал активен (подмена = enable)
|
||||||
|
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `подмена, валидные аргументы, выполнение без второго круга`() {
|
||||||
|
val f = fixture()
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "Рассел")))
|
||||||
|
assertTrue(msg.contains("Результат media_search"))
|
||||||
|
assertEquals(1, f.calls.size)
|
||||||
|
assertTrue(f.calls.single().contains("media.media_search"))
|
||||||
|
assertTrue(f.calls.single().contains("query=Рассел"))
|
||||||
|
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- валидация и маршрутизация ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `активный тулсет, не хватает аргументов, ошибка со схемой`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
|
||||||
|
assertTrue(msg.contains("аргументы неверны"))
|
||||||
|
assertTrue(msg.contains("query"))
|
||||||
|
assertTrue(msg.contains("Схема: media_search|query"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `маршрутизация по префкису в набор, вызов идёт в executor`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "media_info", mapOf("id" to "123")))
|
||||||
|
assertTrue(msg.contains("Результат media_info"))
|
||||||
|
assertTrue(f.calls.single().contains("media.media_info"))
|
||||||
|
assertTrue(f.calls.single().contains("id=123"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `тул не входит в сигнатуры набора`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "media_bogus", emptyMap()))
|
||||||
|
assertTrue(msg.contains("Тул \"media_bogus\" в тулсете \"media\" нет"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `неизвестный тул, ответ со списком доступных`() {
|
||||||
|
val msg = run(fixture().toolkit.dispatch(1, "frobo", emptyMap()))
|
||||||
|
assertTrue(msg.contains("Тул \"frobo\" не найден"))
|
||||||
|
assertTrue(msg.contains("enable_toolset"))
|
||||||
|
assertTrue(msg.contains("media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `активный тулсет без исполнителя, понятное сообщение`() {
|
||||||
|
val f = fixture(withExecutor = false)
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "x")))
|
||||||
|
assertTrue(msg.contains("ещё не реализованы"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ядро тулов ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `read_skill, весь текст скилла`() {
|
||||||
|
val f = fixture()
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "media")))
|
||||||
|
assertTrue(msg.contains("ТЕЛО: media_search|query=..."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `read_skill неизвестный скилл, прощающая подсказка`() {
|
||||||
|
val f = fixture()
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "неттакого")))
|
||||||
|
assertTrue(msg.contains("Скилл \"неттакого\" не найден"))
|
||||||
|
assertTrue(msg.contains("media"))
|
||||||
|
assertTrue(msg.contains("web"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `show_text, текст шлётся в шир, результат и пропуск без дисплея`() {
|
||||||
|
val f = fixture()
|
||||||
|
val msg = run(f.toolkit.dispatch(1, "show_text", mapOf("text" to "Привет!")))
|
||||||
|
assertEquals("Привет!", f.shown.single())
|
||||||
|
assertTrue(msg.contains("Показано на очках: Привет!"))
|
||||||
|
val bad = run(f.toolkit.dispatch(1, "show_text", emptyMap()))
|
||||||
|
assertTrue(bad.contains("\"text\""))
|
||||||
|
// без подключённого дисплея (showText = null) — понятный результат, не падение
|
||||||
|
val bare = AgentToolkit(SkillRegistry(emptyList()), ToolsetRegistry(clock = { now }))
|
||||||
|
val disconnected = run(bare.dispatch(1, "show_text", mapOf("text" to "X")))
|
||||||
|
assertTrue(disconnected.contains("не показан"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get_current_time, время в ISO`() {
|
||||||
|
val msg = run(fixture().toolkit.dispatch(1, "get_current_time", emptyMap()))
|
||||||
|
val ts = Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")
|
||||||
|
assertTrue(ts.find(msg) != null, "не ISO 8601 в: $msg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- таймаут и сессии ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `истёкший тулсет исчезает из промпта, скилл возвращается`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||||
|
// инжект часов: проскакиваем таймаут без реального ожидания 10 минут
|
||||||
|
now += ToolsetRegistry.DEFAULT_TIMEOUT_MS + 1_000L
|
||||||
|
val p = f.toolkit.systemPrompt(1)
|
||||||
|
assertFalse(p.contains("Активные тулсеты"))
|
||||||
|
assertFalse(p.contains("media_search|query"))
|
||||||
|
assertTrue(p.contains("media — Поиск фильмов")) // скилл снова в индексе
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `активность по сессиям, разные сессии независимы`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
|
||||||
|
// в сессии 2 вызов тула неактивного набора — подмена активирует только её
|
||||||
|
val msg = run(f.toolkit.dispatch(2, "media_search", mapOf("query" to "X")))
|
||||||
|
assertTrue(msg.contains("Результат media_search"))
|
||||||
|
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||||
|
assertTrue(f.toolkit.systemPrompt(2).contains("Активные тулсеты: media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disable в сессии 2 не трогает активный в сессии 1`() {
|
||||||
|
val f = fixture()
|
||||||
|
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
f.toolkit.dispatch(2, "enable_toolset", mapOf("name" to "media"))
|
||||||
|
f.toolkit.dispatch(2, "disable_toolset", mapOf("name" to "media"))
|
||||||
|
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||||
|
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- парсинг аргументов ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `парсинг пар и голых значений, прощающий к лишнему`() {
|
||||||
|
val tk = fixture().toolkit
|
||||||
|
assertEquals(mapOf("name" to "media"), tk.parseArgs("media", listOf("name")))
|
||||||
|
assertEquals(emptyMap<String, String>(), tk.parseArgs(null, listOf("name")))
|
||||||
|
assertEquals(emptyMap<String, String>(), tk.parseArgs(" ", listOf("name")))
|
||||||
|
assertEquals(mapOf("query" to "Расскл"), tk.parseArgs("query=Расскл", listOf("query")))
|
||||||
|
assertEquals(
|
||||||
|
mapOf("id" to "12", "seek_ms" to "300"),
|
||||||
|
tk.parseArgs("id=12,seek_ms=300", listOf("id", "seek_ms?")),
|
||||||
|
)
|
||||||
|
// «голое» значение — первому обязательному аргументу
|
||||||
|
assertEquals(mapOf("id" to "abc"), tk.parseArgs("abc", listOf("id", "seek_ms?")))
|
||||||
|
// пар через «&» и лишние ключи — не ошибка
|
||||||
|
val m = tk.parseArgs("query=x&extra=5", listOf("query"))
|
||||||
|
assertEquals("x", m["query"])
|
||||||
|
assertTrue(m.containsKey("extra"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `схема аргументов, schemaFor, у тулов`() {
|
||||||
|
val tk = fixture().toolkit
|
||||||
|
assertEquals(emptyList(), tk.schemaFor("get_current_time"))
|
||||||
|
assertEquals(listOf("text"), tk.schemaFor("show_text"))
|
||||||
|
assertEquals(listOf("name"), tk.schemaFor("enable_toolset"))
|
||||||
|
assertEquals(listOf("name"), tk.schemaFor("read_skill"))
|
||||||
|
assertEquals(listOf("id", "seek_ms?"), tk.schemaFor("media_play"))
|
||||||
|
assertEquals(emptyList(), tk.schemaFor("unknown_tool"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты тулов ассистента — чистая логика без БД и Android.
|
||||||
|
*/
|
||||||
|
class AgentToolsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `тул времени возвращает ISO 8601 с датой и временем`() {
|
||||||
|
val result = TimeTool.invoke("{}")
|
||||||
|
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
|
||||||
|
assertTrue(
|
||||||
|
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
|
||||||
|
"не ISO 8601: $result",
|
||||||
|
)
|
||||||
|
assertTrue(result.startsWith("20"), "год не 20xx: $result")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `реестр содержит тулу времени с описанием`() {
|
||||||
|
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
|
||||||
|
val desc = AgentTools.describeAll()
|
||||||
|
assertTrue(desc.contains("get_current_time"))
|
||||||
|
assertTrue(desc.contains("ISO 8601"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `тула времени не зависит от аргументов`() {
|
||||||
|
// Пустой JSON и мусор — один результат (тула без параметров)
|
||||||
|
val a = TimeTool.invoke("{}")
|
||||||
|
val b = TimeTool.invoke("{\"x\":1}")
|
||||||
|
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import com.google.ai.edge.litertlm.Contents
|
||||||
|
import com.google.ai.edge.litertlm.Message
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.io.path.createTempFile
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/** Тесты LLM-интерфейса без модели/сети: парсинг выбора, маппинг промпта, SHA-256, фабрика. */
|
||||||
|
class LlmLocalTest {
|
||||||
|
|
||||||
|
// ---------- LlmModelChoice.parse ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun choiceParseDefaultsToRemote() {
|
||||||
|
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(null))
|
||||||
|
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(""))
|
||||||
|
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun choiceParseByNameInsensitive() {
|
||||||
|
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("LOCAL"))
|
||||||
|
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("local"))
|
||||||
|
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("remote"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- buildLocalLlmPrompt ----------
|
||||||
|
|
||||||
|
private fun chatHistory() = listOf(
|
||||||
|
ChatMessage.of(ChatRole.SYSTEM, "Ты Порфирий — голосовой ассистент."),
|
||||||
|
ChatMessage.of(ChatRole.USER, "включи свет"),
|
||||||
|
ChatMessage.of(ChatRole.ASSISTANT, "Свет включён."),
|
||||||
|
ChatMessage.of(ChatRole.TOOL, "выполнено", toolCallId = "c42"),
|
||||||
|
ChatMessage.of(ChatRole.USER, "погоди, выключи"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptMapsRolesToLiteRtLm() {
|
||||||
|
val p = buildLocalLlmPrompt(chatHistory())
|
||||||
|
// системный промпт = SYSTEM-сообщения (системный параметр не задан)
|
||||||
|
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
|
||||||
|
// входящее — последнее непустое USER
|
||||||
|
assertEquals("погоди, выключи", p.incoming.text())
|
||||||
|
// история: user, assistant, tool-результат в «Результат тула…» формате
|
||||||
|
assertEquals(3, p.initialMessages.size)
|
||||||
|
assertEquals("включи свет", p.initialMessages[0].text())
|
||||||
|
assertEquals("Свет включён.", p.initialMessages[1].text())
|
||||||
|
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptFoldsSystemParamAndSystemMessages() {
|
||||||
|
val history = listOf(
|
||||||
|
ChatMessage.of(ChatRole.SYSTEM, "контекст A"),
|
||||||
|
ChatMessage.of(ChatRole.SYSTEM, "контекст B"),
|
||||||
|
ChatMessage.of(ChatRole.USER, "привет"),
|
||||||
|
)
|
||||||
|
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
|
||||||
|
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
|
||||||
|
assertEquals("привет", p.incoming.text())
|
||||||
|
assertTrue(p.initialMessages.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptToolLastIsIncoming() {
|
||||||
|
// последняя непустая — TOOL (результат): входящим идёт именно он
|
||||||
|
val history = listOf(
|
||||||
|
ChatMessage.of(ChatRole.USER, "что в каталоге?"),
|
||||||
|
ChatMessage.of(ChatRole.ASSISTANT, "", toolCalls = listOf(ToolCall("c7", "media_search", "{}"))),
|
||||||
|
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
|
||||||
|
)
|
||||||
|
val p = buildLocalLlmPrompt(history)
|
||||||
|
assertEquals("Результат тула c7: найдено 5", p.incoming.text())
|
||||||
|
// assistant с пустым текстом в историю не попадает
|
||||||
|
assertEquals(1, p.initialMessages.size)
|
||||||
|
assertEquals("что в каталоге?", p.initialMessages[0].text())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptLastIsModelMessageFallsBackToContinue() {
|
||||||
|
val history = listOf(
|
||||||
|
ChatMessage.of(ChatRole.USER, "привет"),
|
||||||
|
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
|
||||||
|
)
|
||||||
|
val p = buildLocalLlmPrompt(history)
|
||||||
|
assertEquals("Продолжай.", p.incoming.text())
|
||||||
|
assertEquals(2, p.initialMessages.size)
|
||||||
|
assertTrue(p.systemInstruction == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptSkipsEmptyContents() {
|
||||||
|
val history = listOf(
|
||||||
|
ChatMessage.of(ChatRole.USER, ""),
|
||||||
|
ChatMessage.of(ChatRole.ASSISTANT, "привет"),
|
||||||
|
ChatMessage.of(ChatRole.USER, "как дела"),
|
||||||
|
)
|
||||||
|
val p = buildLocalLlmPrompt(history)
|
||||||
|
// пустое user-сообщение отброшено; входящее — «как дела»
|
||||||
|
assertEquals(1, p.initialMessages.size)
|
||||||
|
assertEquals("как дела", p.incoming.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageTextExtractsTextParts() {
|
||||||
|
val m = Message.user(Contents.of("привет"))
|
||||||
|
assertEquals("привет", m.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SHA-256 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sha256OfFileMatchesKnownContent() {
|
||||||
|
val file = createTempFile("sha-test").toFile()
|
||||||
|
try {
|
||||||
|
file.writeBytes("hello world".toByteArray())
|
||||||
|
val expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||||
|
assertEquals(expected, sha256HexOfFile(file))
|
||||||
|
assertTrue(sha256OfFileMatches(file, expected))
|
||||||
|
assertTrue(sha256OfFileMatches(file, expected.uppercase()))
|
||||||
|
assertFalse(sha256OfFileMatches(file, "deadbeef"))
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sha256OfFileMatchesFalseOnMissingFile() {
|
||||||
|
assertFalse(sha256OfFileMatches(File("/root/WORK/view-mate/definitely-not-here-llm"), ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- LocalLlmClient ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localClientWithoutModelFailsReadably() {
|
||||||
|
val missing = File(createTempFile("model").toFile().parentFile, "missing.litertlm")
|
||||||
|
val client = LocalLlmClient(modelFile = missing, cacheDir = File(missing.parentFile, "cache"))
|
||||||
|
assertFalse(client.modelDownloaded())
|
||||||
|
runBlocking {
|
||||||
|
val e = assertFailsWith<IllegalStateException> {
|
||||||
|
client.chat(listOf(ChatMessage.of(ChatRole.USER, "привет")))
|
||||||
|
}
|
||||||
|
assertTrue(e.message!!.contains("не скачана"), e.message)
|
||||||
|
}
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GemmaModelDownloader ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloaderAllSourcesFailThrows() {
|
||||||
|
val target = File(createTempFile("model-dl").toFile().parentFile, "model.litertlm")
|
||||||
|
val dl = GemmaModelDownloader(target, urls = listOf("http://127.0.0.1:1/model.litertlm"))
|
||||||
|
val e = assertFailsWith<IOException> {
|
||||||
|
runBlocking { dl.download { } }
|
||||||
|
}
|
||||||
|
assertTrue(e.message!!.startsWith("Модель не скачалась (все источники)"), e.message)
|
||||||
|
assertFalse(target.exists())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.viewmate.core.media.JellyfinClient
|
||||||
|
import pw.binom.viewmate.core.media.JellyfinItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исполнитель тулсета media: валидация аргументов + форматирование текста
|
||||||
|
* (search/info/play). Источник и игрок иньекшены фейками — JVM, без Android.
|
||||||
|
*/
|
||||||
|
class MediaToolsTest {
|
||||||
|
|
||||||
|
private class FakeSource : MediaSource {
|
||||||
|
val items = mutableListOf(
|
||||||
|
MediaItem("i1", "Lara Croft", "Movie", 2018, "Tomb Raider reboot", 8100L, true),
|
||||||
|
MediaItem("i2", "The Witcher", "Series", 2019, null, 3300L, false),
|
||||||
|
MediaItem("i3", "Stranger Things", "Series", 2016, " ", 3540L, true),
|
||||||
|
MediaItem("i4", "Blade Runner", "Movie", 1982, "репликанты", 8340L, true),
|
||||||
|
MediaItem("i5", "Dune", "Series", 2021, null, 4200L, false),
|
||||||
|
)
|
||||||
|
var searchFails = false
|
||||||
|
var itemFails = false
|
||||||
|
/** Фильтр поиска (по умолчанию все — тестируем лимит). */
|
||||||
|
var match: (MediaItem) -> Boolean = { true }
|
||||||
|
|
||||||
|
override suspend fun search(query: String, limit: Int): List<MediaItem> {
|
||||||
|
if (searchFails) throw MediaSourceException("нет пользователя Jellyfin (userId пуст)")
|
||||||
|
return items.filter(match).take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun item(itemId: String): MediaItem? {
|
||||||
|
if (itemFails) throw MediaSourceException("сеть недоступна")
|
||||||
|
return items.firstOrNull { it.id == itemId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakePlayer(
|
||||||
|
private val responses: Map<String, String> = emptyMap(),
|
||||||
|
private var failure: Throwable? = null,
|
||||||
|
) : MediaPlayer {
|
||||||
|
val calls = mutableListOf<Pair<String, Long>>()
|
||||||
|
|
||||||
|
override suspend fun play(itemId: String, seekMs: Long): String {
|
||||||
|
calls.add(itemId to seekMs)
|
||||||
|
failure?.let { throw it }
|
||||||
|
return responses[itemId] ?: "включил: $itemId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executor(source: FakeSource = FakeSource(), player: FakePlayer = FakePlayer()) =
|
||||||
|
MediaToolExecutor(source, player)
|
||||||
|
|
||||||
|
// ---------- определение тулсета ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `сигнатуры тулсета и префиксная маршрутизация`() {
|
||||||
|
assertEquals(listOf("search", "info", "play"), MEDIA_TOOLSET.tools.map { it.name })
|
||||||
|
assertEquals("media_search", MEDIA_TOOLSET.toolFull(MEDIA_TOOLSET.tools.first()))
|
||||||
|
assertEquals(listOf("id"), MEDIA_TOOLSET.signatureOf("media_play")?.requiredArgs())
|
||||||
|
assertEquals(listOf("query", "limit"), MEDIA_TOOLSET.signatureOf("media_search")?.argNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- media_search ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search строки id название год тип`() {
|
||||||
|
val source = FakeSource()
|
||||||
|
source.match = { it.id == "i1" }
|
||||||
|
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
|
||||||
|
assertEquals("i1: Lara Croft (2018) — Movie", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `поиск без года не показывает скобок`() {
|
||||||
|
val source = FakeSource()
|
||||||
|
source.items[0] = source.items[0].copy(year = null)
|
||||||
|
source.match = { it.id == "i1" }
|
||||||
|
val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара"))
|
||||||
|
assertEquals("i1: Lara Croft — Movie", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `limit по умолчанию 5, явного, кривой и ноль`() {
|
||||||
|
val r = executor()
|
||||||
|
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x")).lines().size)
|
||||||
|
assertEquals(2, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "2")).lines().size)
|
||||||
|
assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "ab")).lines().size)
|
||||||
|
// ноль и отрицательное — сжимаются в 1
|
||||||
|
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "0")).lines().size)
|
||||||
|
assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "-4")).lines().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `поиск без query — прощающая схема`() {
|
||||||
|
for (args in listOf(emptyMap<String, String>(), mapOf("query" to ""))) {
|
||||||
|
val r = executor().execute("media", "media_search", args)
|
||||||
|
assertTrue(r.contains("нет аргумента \"query\""), r)
|
||||||
|
assertTrue(r.contains("media_search|<query>"), r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `поиск пустая — ничего не найдено`() {
|
||||||
|
val source = FakeSource()
|
||||||
|
source.match = { false }
|
||||||
|
val r = executor(source).execute("media", "media_search", mapOf("query" to "такого нет"))
|
||||||
|
assertTrue(r.contains("Ничего не найдено"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `поиск источник недоступен — сообщение с причиной`() {
|
||||||
|
val source = FakeSource()
|
||||||
|
source.searchFails = true
|
||||||
|
val r = executor(source).execute("media", "media_search", mapOf("query" to "x"))
|
||||||
|
assertTrue(r.contains("Поиск недоступен"), r)
|
||||||
|
assertTrue(r.contains("userId пуст"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- media_info ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `info полный набор полей`() {
|
||||||
|
val r = executor().execute("media", "media_info", mapOf("id" to "i1"))
|
||||||
|
assertTrue(r.contains("«Lara Croft» — Movie (2018)"), r)
|
||||||
|
assertTrue(r.contains("Описание: Tomb Raider reboot"), r)
|
||||||
|
assertTrue(r.contains("Длительность: 135 мин. (8100 сек.)"), r)
|
||||||
|
assertTrue(r.contains("Постер: есть"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `info без описания и постера — строки не выдумываются`() {
|
||||||
|
val r = executor().execute("media", "media_info", mapOf("id" to "i2"))
|
||||||
|
assertFalse(r.contains("Описание"), r)
|
||||||
|
assertFalse(r.contains("8100"), r)
|
||||||
|
assertTrue(r.contains("Постер: нет"), r)
|
||||||
|
assertTrue(r.contains("55 мин. (3300 сек.)"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `info описание длиннее 300 символов — обрезка с многоточием`() {
|
||||||
|
val source = FakeSource()
|
||||||
|
source.items[0] = source.items[0].copy(overview = "А".repeat(400))
|
||||||
|
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
|
||||||
|
assertTrue(r.contains("Описание: ${"А".repeat(300)}…"), r)
|
||||||
|
assertFalse(r.contains("А".repeat(301)), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `info не найден и без аргумента`() {
|
||||||
|
val r = executor().execute("media", "media_info", mapOf("id" to "iX"))
|
||||||
|
assertTrue(r.contains("\"iX\" не найден"), r)
|
||||||
|
val noArg = executor().execute("media", "media_info", emptyMap())
|
||||||
|
assertTrue(noArg.contains("нет аргумента \"id\""), noArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `info источник недоступен — сообщение с причиной`() {
|
||||||
|
val source = FakeSource()
|
||||||
|
source.itemFails = true
|
||||||
|
val r = executor(source).execute("media", "media_info", mapOf("id" to "i1"))
|
||||||
|
assertTrue(r.contains("Детали недоступны"), r)
|
||||||
|
assertTrue(r.contains("сеть недоступна"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- media_play ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play передаёт id и seek_ms в игрок`() {
|
||||||
|
val player = FakePlayer()
|
||||||
|
val executor = executor(player = player)
|
||||||
|
val r = executor.execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "3600000"))
|
||||||
|
assertEquals("включил: i1", r)
|
||||||
|
assertEquals(listOf("i1" to 3_600_000L), player.calls)
|
||||||
|
// без seek — ноль
|
||||||
|
assertEquals("включил: i2", executor.execute("media", "media_play", mapOf("id" to "i2")))
|
||||||
|
assertEquals(2, player.calls.size)
|
||||||
|
assertEquals(0L, player.calls.last().second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play без id — схема аргументов`() {
|
||||||
|
val r = executor().execute("media", "media_play", emptyMap())
|
||||||
|
assertTrue(r.contains("нет аргумента \"id\""), r)
|
||||||
|
assertTrue(r.contains("media_play|<id>"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play кривой seek_ms — числовое требование`() {
|
||||||
|
for (bad in listOf("час", "12.5")) {
|
||||||
|
val r = executor().execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to bad))
|
||||||
|
assertTrue(r.contains("\"seek_ms\" должен быть числом"), r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play отрицательный seek — в ноль`() {
|
||||||
|
val player = FakePlayer()
|
||||||
|
executor(player = player).execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "-100"))
|
||||||
|
assertEquals(0L, player.calls.single().second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play игрок выбросил — обёртка ошибки`() {
|
||||||
|
val player = FakePlayer(failure = RuntimeException("видео не найдено"))
|
||||||
|
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
|
||||||
|
assertTrue(r.contains("Ошибка воспроизведения"), r)
|
||||||
|
assertTrue(r.contains("видео не найдено"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play текст проверки игрока проходит как есть`() {
|
||||||
|
val player = FakePlayer(mapOf("i1" to "видео не скачано на очки. Скачайте его."))
|
||||||
|
val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1"))
|
||||||
|
assertEquals("видео не скачано на очки. Скачайте его.", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `неизвестный тул — список доступных`() {
|
||||||
|
val r = executor().execute("media", "media_bogus", emptyMap())
|
||||||
|
assertTrue(r.contains("Неизвестный тул"), r)
|
||||||
|
assertTrue(r.contains("media_search, media_info, media_play"), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- маппинг Jellyfin → MediaItem ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `маппинг полей, постер, секунды из тиков`() {
|
||||||
|
val item = JellyfinItem(
|
||||||
|
Id = "abc",
|
||||||
|
Name = " Blade ",
|
||||||
|
Type = "Movie",
|
||||||
|
ProductionYear = 2259,
|
||||||
|
Overview = "текст",
|
||||||
|
RunTimeTicks = 33_000_000_000L, // 3300 сек
|
||||||
|
ImageTags = mapOf("Primary" to "hash"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
MediaItem("abc", "Blade", "Movie", 2259, "текст", 3300L, true),
|
||||||
|
jellyfinToMediaItem(item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `маппинг пустое имя в id, без постера и секунд`() {
|
||||||
|
val m = jellyfinToMediaItem(JellyfinItem(Id = "x1", Name = " ", Type = "Episode"))
|
||||||
|
assertEquals("x1", m.name)
|
||||||
|
assertFalse(m.hasPoster)
|
||||||
|
assertEquals(null, m.year)
|
||||||
|
assertEquals(null, m.durationSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- JellyfinMediaSource: защита userId ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `источник без пользователя — исключение у источника`() {
|
||||||
|
val source = JellyfinMediaSource(JellyfinClient("http://jellyfin.test", "key")) { " " }
|
||||||
|
runBlocking {
|
||||||
|
assertFailsWith<MediaSourceException> { source.search("x", 5) }
|
||||||
|
assertFailsWith<MediaSourceException> { source.item("i1") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// Скиллы: реестр (JVM, без Android) — файлы skills/*.md в приложении.
|
||||||
|
class SkillsTest {
|
||||||
|
|
||||||
|
private fun freshDir(): File =
|
||||||
|
File.createTempFile("vw-skills", "").also { it.delete(); it.mkdirs() }
|
||||||
|
|
||||||
|
private fun write(dir: File, fileName: String, content: String) {
|
||||||
|
File(dir, fileName).writeText(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `файл с frontmatter парсится в скилл`() {
|
||||||
|
val dir = freshDir()
|
||||||
|
write(dir, "media.md", """
|
||||||
|
---
|
||||||
|
name: media
|
||||||
|
---
|
||||||
|
Вызови enable_toolset|media и дождись реестра тул.
|
||||||
|
1. media_search|query=<название>
|
||||||
|
""".trimIndent())
|
||||||
|
val skills = SkillRepository(dir).loadAll()
|
||||||
|
assertEquals(1, skills.size)
|
||||||
|
val s = skills.single()
|
||||||
|
assertEquals("media", s.name)
|
||||||
|
assertTrue(s.body.contains("enable_toolset|media"))
|
||||||
|
// frontmatter (заголовок) не должен попасть в тело
|
||||||
|
assertFalse(s.body.startsWith("---"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `description в frontmatter становится подсказкой (триггером)`() {
|
||||||
|
val dir = freshDir()
|
||||||
|
write(dir, "web.md", """
|
||||||
|
---
|
||||||
|
name: web
|
||||||
|
description: Как сделать веб-поиск.
|
||||||
|
---
|
||||||
|
Шаги поиска...
|
||||||
|
""".trimIndent())
|
||||||
|
val s = SkillRepository(dir).loadAll().single()
|
||||||
|
assertEquals("web", s.name)
|
||||||
|
assertEquals("Как сделать веб-поиск.", s.trigger)
|
||||||
|
assertTrue(s.body.contains("Шаги поиска"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `файл без frontmatter берёт имя из имени файла`() {
|
||||||
|
val dir = freshDir()
|
||||||
|
write(dir, "tips.md", "Советы: запускать воспроизведение сразу после буферизации.")
|
||||||
|
val s = SkillRepository(dir).loadAll().single()
|
||||||
|
assertEquals("tips", s.name)
|
||||||
|
assertEquals("Советы: запускать воспроизведение сразу после буферизации.", s.body)
|
||||||
|
assertEquals("", s.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `пустой каталог и несуществующий каталог — пустой список`() {
|
||||||
|
val empty = freshDir()
|
||||||
|
assertTrue(SkillRepository(empty).loadAll().isEmpty())
|
||||||
|
assertTrue(SkillRepository(File("no-such-skills-dir")).loadAll().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `реестр строки индекса и имена`() {
|
||||||
|
val reg = SkillRegistry(
|
||||||
|
listOf(
|
||||||
|
Skill("media", "Поиск фильмов, детали, запуск/управление.", "тело1"),
|
||||||
|
Skill("web", "Как сделать веб-поиск.", "тело2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("media", "web"), reg.names())
|
||||||
|
val lines = reg.index()
|
||||||
|
assertEquals(2, lines.size)
|
||||||
|
assertTrue(lines.any { it.startsWith("media — ") })
|
||||||
|
assertTrue(lines.any { it.startsWith("web — ") })
|
||||||
|
assertTrue(reg.index(excluded = setOf("media")).all { !it.startsWith("media") })
|
||||||
|
assertTrue(reg.index(excluded = emptySet()) == lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `readFull полный текст, неизвестный скилл — null и has`() {
|
||||||
|
val reg = SkillRegistry(listOf(Skill("media", "триггер", "текст_тела")))
|
||||||
|
val full = reg.readFull("media")
|
||||||
|
assertNotNull(full)
|
||||||
|
assertTrue(full.contains("триггер"))
|
||||||
|
assertTrue(full.contains("текст_тела"))
|
||||||
|
assertNull(reg.readFull("other"))
|
||||||
|
assertTrue(reg.has("media"))
|
||||||
|
assertFalse(reg.has("other"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package pw.binom.viewmate.phone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр тулсетов: активность на сессию, таймаут (инжект часов — без ожидания
|
||||||
|
* реальных 10 минут), идемпотентность enable/disable, префиксная маршрутизация.
|
||||||
|
*/
|
||||||
|
class ToolsetRegistryTest {
|
||||||
|
|
||||||
|
private var now = 1_000_000L
|
||||||
|
|
||||||
|
private fun newRegistry(timeoutMs: Long = ToolsetRegistry.DEFAULT_TIMEOUT_MS): ToolsetRegistry {
|
||||||
|
now = 1_000_000L
|
||||||
|
return ToolsetRegistry(timeoutMs = timeoutMs, clock = { now })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun media(): Toolset = Toolset(
|
||||||
|
name = "media",
|
||||||
|
description = "Управление медиа и воспроизведением (search/info/play)",
|
||||||
|
tools = listOf(
|
||||||
|
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
|
||||||
|
ToolSignature("info", "Детали фильма.", args = listOf("id")),
|
||||||
|
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enable активирует набор, активность на сессию`() {
|
||||||
|
val r = newRegistry()
|
||||||
|
r.register(media())
|
||||||
|
val result = r.enable(1, "media")
|
||||||
|
assertTrue(result is EnableResult.Activated)
|
||||||
|
assertEquals(listOf("media"), r.activeNames(1))
|
||||||
|
assertTrue(r.isActive(1, "media"))
|
||||||
|
// другая сессия — не активна: активность НЕ персистится и НЕ общее
|
||||||
|
assertFalse(r.isActive(2, "media"))
|
||||||
|
assertEquals(emptyList(), r.activeNames(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `повторный enable — продление, не ошибка`() {
|
||||||
|
val r = newRegistry(timeoutMs = 600_000)
|
||||||
|
r.register(media())
|
||||||
|
r.enable(1, "media")
|
||||||
|
now += 9 * 60_000
|
||||||
|
val result = r.enable(1, "media") as EnableResult.Activated
|
||||||
|
assertTrue(result.alreadyActive)
|
||||||
|
now += 9 * 60_000 // 18 минут суммарно, но от последнего вызова — 9
|
||||||
|
assertTrue(r.isActive(1, "media"))
|
||||||
|
now += 600_001L
|
||||||
|
assertFalse(r.isActive(1, "media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `истёкший таймаут чистится лениво`() {
|
||||||
|
val r = newRegistry(timeoutMs = 600_000)
|
||||||
|
r.register(media())
|
||||||
|
r.enable(1, "media")
|
||||||
|
now += 600_001L
|
||||||
|
assertEquals(emptyList(), r.activeNames(1))
|
||||||
|
assertFalse(r.isActive(1, "media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disable идемпотентен, не ошибка`() {
|
||||||
|
val r = newRegistry()
|
||||||
|
r.register(media())
|
||||||
|
val first = r.disable(1, "media") as DisableResult.NotActive
|
||||||
|
assertTrue(first.message.contains("Нет активных тулсетов"))
|
||||||
|
r.enable(1, "media")
|
||||||
|
assertTrue(r.disable(1, "media") is DisableResult.Disabled)
|
||||||
|
val again = r.disable(1, "media") as DisableResult.NotActive
|
||||||
|
assertTrue(again.message.contains("Нет активных тулсетов"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disable другого набора, когда активен свой — сообщение без «нет активных»`() {
|
||||||
|
val r = newRegistry()
|
||||||
|
r.register(media())
|
||||||
|
r.register(Toolset("web", "Работа с вебом.", emptyList()))
|
||||||
|
r.enable(1, "web")
|
||||||
|
val res = r.disable(1, "media") as DisableResult.NotActive
|
||||||
|
assertTrue(res.message.contains("\"media\" не активен"))
|
||||||
|
// активный «web» затронут не был
|
||||||
|
assertTrue(r.isActive(1, "web"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `неизвестный набор — сообщение с доступными в ответе`() {
|
||||||
|
val r = newRegistry()
|
||||||
|
r.register(media())
|
||||||
|
val unknown = r.enable(1, "other") as EnableResult.Unknown
|
||||||
|
assertEquals("other", unknown.name)
|
||||||
|
assertTrue(unknown.message.contains("\"other\""))
|
||||||
|
assertTrue(unknown.message.contains("media"))
|
||||||
|
|
||||||
|
val emptyReg = newRegistry()
|
||||||
|
val empty = emptyReg.enable(1, "other") as EnableResult.Unknown
|
||||||
|
assertTrue(empty.message.contains("Доступных тулсетов нет"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `маршрутизация по префкису и сигнатура по полному имени`() {
|
||||||
|
val r = newRegistry()
|
||||||
|
r.register(media())
|
||||||
|
assertEquals("media", r.findByPrefix("media_search")?.name)
|
||||||
|
assertEquals("media", r.findByPrefix("media_play")?.name)
|
||||||
|
// без подчёркивания это имя набора, не тул
|
||||||
|
assertEquals(null, r.findByPrefix("media"))
|
||||||
|
assertEquals(null, r.findByPrefix("web_search"))
|
||||||
|
assertEquals("search", r.signatureOf("media_search")?.name)
|
||||||
|
assertEquals(listOf("id", "seek_ms?"), r.signatureOf("media_play")?.args)
|
||||||
|
assertEquals(null, r.signatureOf("media_bogus"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `touch продлевает таймаут на каждом вызове тулы набора`() {
|
||||||
|
val r = newRegistry(timeoutMs = 600_000)
|
||||||
|
r.register(media())
|
||||||
|
r.enable(1, "media")
|
||||||
|
now += 9 * 60_000
|
||||||
|
r.touch(1, "media")
|
||||||
|
now += 9 * 60_000 // от последнего touch — снова 9 минут
|
||||||
|
assertTrue(r.isActive(1, "media"))
|
||||||
|
now += 600_001L
|
||||||
|
assertFalse(r.isActive(1, "media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `каталог и активные — независимы, строки каталога по одной на набор`() {
|
||||||
|
val r = newRegistry()
|
||||||
|
r.register(media())
|
||||||
|
r.register(Toolset("web", "Работа с вебом.", emptyList()))
|
||||||
|
val catalog = r.catalogLines()
|
||||||
|
assertEquals(2, catalog.size)
|
||||||
|
assertTrue(catalog.any { it.startsWith("media — ") })
|
||||||
|
assertTrue(catalog.any { it.startsWith("web — ") })
|
||||||
|
// каталог есть, активных нет
|
||||||
|
assertEquals(emptyList(), r.activeNames(7))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
/** Тестовый [PhraseRecognizer]: любая фраза → "привет" (первая строка списка). */
|
||||||
|
class FakePhraseRecognizer(
|
||||||
|
private val phrases: List<String> = listOf("привет"),
|
||||||
|
) : PhraseRecognizer {
|
||||||
|
|
||||||
|
override fun recognize(samples: FloatArray): String = phrases[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полный текст по клику (TZ-STT): [SttStreamer] накапливает весь PCM сессии
|
||||||
|
* (без VAD) и [SttStreamer.finish] прогоняет буфер (или хвост 120 с) одним
|
||||||
|
* вызовом [PhraseRecognizer] с обрезанием краевой тишины, перезаписывая text.
|
||||||
|
*/
|
||||||
|
class FullBufferSttTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
|
||||||
|
* по одному на вызов (последний — на все лишние). [calls] читается
|
||||||
|
* после [SttStreamer.finish] — executor к этому моменту уже отработал
|
||||||
|
* все задачи (finish дожидается их через Future).
|
||||||
|
*/
|
||||||
|
private class RecordingRecognizer(vararg val responses: String) : PhraseRecognizer {
|
||||||
|
val calls = mutableListOf<FloatArray>()
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
override fun recognize(samples: FloatArray): String {
|
||||||
|
calls.add(samples)
|
||||||
|
val answer = responses[minOf(count, responses.size - 1)]
|
||||||
|
count += 1
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** s16le-чанк: [count] сэмплов с глобальных индексов [offset] (зубчатая волна, период 32768). */
|
||||||
|
private fun pcmChunk(offset: Int, count: Int): ByteArray {
|
||||||
|
val out = ByteArray(count * 2)
|
||||||
|
var i = 0
|
||||||
|
while (i < count) {
|
||||||
|
val u = (offset + i) % 32768
|
||||||
|
out[2 * i] = (u and 0xff).toByte()
|
||||||
|
out[2 * i + 1] = ((u ushr 8) and 0xff).toByte()
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Точная обратная сторона [s16leToFloat] для нашей пачки (по глобальному индексу). */
|
||||||
|
private fun expectedFloat(globalIndex: Int): Float =
|
||||||
|
(globalIndex % 32768) / 32768f
|
||||||
|
|
||||||
|
private fun newStreamer(recognizer: RecordingRecognizer) = SttStreamer(
|
||||||
|
stt = recognizer,
|
||||||
|
onSilence30s = { },
|
||||||
|
onAutoFinished = { },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
|
||||||
|
private fun acceptShortSession(streamer: SttStreamer) {
|
||||||
|
var off = 0
|
||||||
|
repeat(3) {
|
||||||
|
streamer.accept(pcmChunk(off, 1600))
|
||||||
|
off += 1600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Короткая сессия (0.3 с): finish → ОДИН вызов на весь буфер (4800 сэмплов), full = текст. */
|
||||||
|
@Test
|
||||||
|
fun shortBufferIsRecognizedInOneCall() {
|
||||||
|
val recognizer = RecordingRecognizer("привет")
|
||||||
|
val streamer = newStreamer(recognizer)
|
||||||
|
try {
|
||||||
|
acceptShortSession(streamer)
|
||||||
|
val full = streamer.finish()
|
||||||
|
assertEquals(1, recognizer.calls.size, "один вызов на весь буфер, без VAD-сегментации")
|
||||||
|
assertEquals(4800, recognizer.calls[0].size)
|
||||||
|
assertContentEquals(FloatArray(4800) { expectedFloat(it) }, recognizer.calls[0])
|
||||||
|
assertEquals("привет", full)
|
||||||
|
} finally {
|
||||||
|
streamer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов (текст не пуст).
|
||||||
|
* Хвост — сплошной зубец без пауз → splitPhrases даёт одну длинную фразу (120 с),
|
||||||
|
* которая режется страховкой на куски ≤ 30 с: 4 вызова по 30 с.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun longBufferPassesLastTwoMinutes() {
|
||||||
|
val recognizer = RecordingRecognizer("ок")
|
||||||
|
val streamer = newStreamer(recognizer)
|
||||||
|
try {
|
||||||
|
val total = 130 * 16_000
|
||||||
|
var off = 0
|
||||||
|
while (off < total) {
|
||||||
|
streamer.accept(pcmChunk(off, 1600))
|
||||||
|
off += 1600
|
||||||
|
}
|
||||||
|
val full = streamer.finish()
|
||||||
|
assertEquals(4, recognizer.calls.size, "120 с непрерывной речи режется на 4 фразы ≤ 30 с")
|
||||||
|
val sumLen = recognizer.calls.sumOf { it.size }
|
||||||
|
assertEquals(16_000 * 120, sumLen, "в сумме ровно хвост 120 с")
|
||||||
|
recognizer.calls.forEach { assertTrue(it.size <= 30 * 16_000, "каждая фраза ≤ 30 с") }
|
||||||
|
assertContentEquals(
|
||||||
|
FloatArray(30 * 16_000) { expectedFloat(total - 16_000 * 120 + it) },
|
||||||
|
recognizer.calls[0],
|
||||||
|
"первая фраза — начало хвоста, а не начало буфера",
|
||||||
|
)
|
||||||
|
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
|
||||||
|
} finally {
|
||||||
|
streamer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Две фразы (1.5 с зуба каждая), пауза 0.7 с нулей между ними → распознаны
|
||||||
|
* отдельно (calls.size == 2), склеены через пробел (полный буфер = "А Б").
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun twoPhrasesSplitByPauseRecognizedSeparately() {
|
||||||
|
val recognizer = RecordingRecognizer("А", "Б")
|
||||||
|
val streamer = newStreamer(recognizer)
|
||||||
|
try {
|
||||||
|
val phraseLen = 24_000 // 1.5 с = 75 окон (= minPhraseWindows, не обрубок)
|
||||||
|
val pauseLen = 11_200 // 0.7 с нулей → ≥ 0.6 с, разделяет фразы
|
||||||
|
streamer.accept(pcmChunk(0, phraseLen))
|
||||||
|
streamer.accept(ByteArray(pauseLen * 2))
|
||||||
|
streamer.accept(pcmChunk(phraseLen, phraseLen))
|
||||||
|
val full = streamer.finish()
|
||||||
|
assertEquals(2, recognizer.calls.size, "пауза 0.7 с разделила фразы")
|
||||||
|
assertEquals("А Б", full, "фразы склеены пробелом")
|
||||||
|
} finally {
|
||||||
|
streamer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
|
||||||
|
@Test
|
||||||
|
fun resetThenFinishSkipsRecognizer() {
|
||||||
|
val recognizer = RecordingRecognizer("привет")
|
||||||
|
val streamer = newStreamer(recognizer)
|
||||||
|
try {
|
||||||
|
acceptShortSession(streamer)
|
||||||
|
streamer.reset()
|
||||||
|
val full = streamer.finish()
|
||||||
|
assertEquals(0, recognizer.calls.size, "после reset буфер пуст — whisper не вызывается")
|
||||||
|
assertEquals("", full)
|
||||||
|
} finally {
|
||||||
|
streamer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на
|
||||||
|
* фразы по паузам тишины (≥ 0.6 с), чтобы каждая фраза (whisper ≤ 30 с)
|
||||||
|
* распознавалась отдельно.
|
||||||
|
*
|
||||||
|
* «Речь» — зубчатая волна [0,1) (RMS окна много выше порога 0.005), «тишина» —
|
||||||
|
* нули. Порог/пауза/окно по умолчанию: окно 320 = 20 мс @ 16 кГц, пауза 0.6 с
|
||||||
|
* (25…30 окон), объединение < 1.5 с, страховка ≤ 30 с.
|
||||||
|
*/
|
||||||
|
class PhraseSplitTest {
|
||||||
|
private val win = 320
|
||||||
|
|
||||||
|
/** Зубчатая волна [0,1): «речь» — каждое окно даёт RMS много выше порога. */
|
||||||
|
private fun saw(offset: Int, count: Int): FloatArray =
|
||||||
|
FloatArray(count) { ((offset + it) % 32768) / 32768f }
|
||||||
|
|
||||||
|
private fun silence(count: Int): FloatArray = FloatArray(count)
|
||||||
|
|
||||||
|
private fun concat(vararg parts: FloatArray): FloatArray {
|
||||||
|
var total = 0
|
||||||
|
for (p in parts) total += p.size
|
||||||
|
val out = FloatArray(total)
|
||||||
|
var i = 0
|
||||||
|
for (p in parts) {
|
||||||
|
p.copyInto(out, i)
|
||||||
|
i += p.size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasSound(phrase: FloatArray): Boolean = phrase.any { it > 0.01f }
|
||||||
|
|
||||||
|
/** Две фразы (~2 с), пауза 0.7 с → разделяются; обе содержат речь. */
|
||||||
|
@Test
|
||||||
|
fun twoPhrasesSeparatedByLongPause() {
|
||||||
|
val a = saw(0, 32_000)
|
||||||
|
val pause = silence(11_200) // 0.7 с
|
||||||
|
val b = saw(32_000, 32_000)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(2, phrases.size)
|
||||||
|
phrases.forEach { assertTrue(hasSound(it), "фраза содержит звучный участок") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пауза 0.4 с (< 0.6 с) не разделяет → одна фраза. */
|
||||||
|
@Test
|
||||||
|
fun shortPauseDoesNotSplit() {
|
||||||
|
val a = saw(0, 16_000)
|
||||||
|
val pause = silence(6_400) // 0.4 с
|
||||||
|
val b = saw(16_000, 16_000)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тихие начала слов не режутся: порог 0.005, низкоамплитудные окна не рвут фразу. */
|
||||||
|
@Test
|
||||||
|
fun quietWordStartIsKeptWhole() {
|
||||||
|
// 5 окон почти тишины (ниже порога) + резкий старт → содержимое остаётся одной фразой,
|
||||||
|
// тихие окна начала сохраняются в фразе (не выброшены).
|
||||||
|
val quietHead = silence(5 * win)
|
||||||
|
val loudStart = FloatArray(win) { 0.05f }
|
||||||
|
val speech = saw(0, win * 100) // 100 окон речи (2 с) — не объединяется как обрубок
|
||||||
|
val samples = concat(quietHead, loudStart, speech)
|
||||||
|
val phrases = splitPhrases(samples)
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
assertTrue(phrases[0].size >= (5 + 1 + 100) * win, "тихие окна начала сохранены")
|
||||||
|
assertTrue(hasSound(phrases[0]), "фраза содержит речь")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всё тишина → пустой список. */
|
||||||
|
@Test
|
||||||
|
fun allSilenceReturnsEmpty() {
|
||||||
|
assertEquals(0, splitPhrases(silence(48_000)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустой вход → пустой список. */
|
||||||
|
@Test
|
||||||
|
fun emptyInputReturnsEmpty() {
|
||||||
|
assertEquals(0, splitPhrases(FloatArray(0)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Фраза 31 с → разрезана на куски, каждый ≤ 30 с. */
|
||||||
|
@Test
|
||||||
|
fun phraseLongerThanThirtySecondsIsSplit() {
|
||||||
|
val long = saw(0, 31 * 16_000)
|
||||||
|
val phrases = splitPhrases(long)
|
||||||
|
assertEquals(2, phrases.size)
|
||||||
|
phrases.forEach { assertTrue(it.size <= 30 * 16_000, "кусок ≤ 30 с") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Две короткие фразы (0.8 с + 0.9 с, пауза 0.7 с) объединяются в одну. */
|
||||||
|
@Test
|
||||||
|
fun shortPhrasesAreMerged() {
|
||||||
|
val a = saw(0, 12_800) // 0.8 с = 40 окон (< 75)
|
||||||
|
val pause = silence(11_200)
|
||||||
|
val b = saw(12_800, 14_400) // 0.9 с = 45 окон (< 75)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import pw.binom.voice.vad.VadInstance
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестовый стриминговый VAD на реальном Silero (JVM, ONNX через [VadInstance]).
|
||||||
|
* Постпроцесс — поведенческий паритет с боевой SherpaStreamingVad:
|
||||||
|
* threshold 0.5; minSilence = minSpeech = 0.25 с = 4000 сэмплов;
|
||||||
|
* maxSpeech = 20 с = 320 000 сэмплов; окно 512 сэмплов.
|
||||||
|
*
|
||||||
|
* ВНИМАНИЕ: `buf` хранит все сэмплы с начала стрима (60 с ≈ 3.9 МБ) —
|
||||||
|
* для тестов допустимо, для боевого кода не предлагать.
|
||||||
|
*/
|
||||||
|
class SileroStreamingVad : StreamingVad {
|
||||||
|
|
||||||
|
private val vad = VadInstance(16000)
|
||||||
|
|
||||||
|
private var inSpeech = false
|
||||||
|
private var segStart = 0
|
||||||
|
private var lastSpeechEnd = 0
|
||||||
|
private var total = 0 // обработано сэмплов
|
||||||
|
private val buf = ArrayList<Float>() // все сэмплы от начала, для среза сегментов
|
||||||
|
private val queue = ArrayDeque<VadSegment>()
|
||||||
|
|
||||||
|
override val windowSizeSamples: Int = 512
|
||||||
|
|
||||||
|
override fun acceptWindow(window: FloatArray) {
|
||||||
|
require(window.size == 512) { "Окно должно быть 512 сэмплов, подано " + window.size }
|
||||||
|
val p = vad.processingWindow(window)
|
||||||
|
val s = total
|
||||||
|
val sEnd = s + 512
|
||||||
|
for (w in window) {
|
||||||
|
buf.add(w)
|
||||||
|
}
|
||||||
|
total = sEnd
|
||||||
|
if (p >= 0.5f) {
|
||||||
|
if (!inSpeech) {
|
||||||
|
inSpeech = true
|
||||||
|
segStart = s
|
||||||
|
}
|
||||||
|
lastSpeechEnd = sEnd
|
||||||
|
// maxSpeech: 20 с — сегмент принудительно закрывается.
|
||||||
|
if (lastSpeechEnd - segStart >= 320_000) {
|
||||||
|
emit()
|
||||||
|
inSpeech = false
|
||||||
|
}
|
||||||
|
} else if (inSpeech && sEnd - lastSpeechEnd >= 4000) {
|
||||||
|
// minSilence: 0.25 с тишины после речи — сегмент готов.
|
||||||
|
if (lastSpeechEnd - segStart >= 4000) {
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
inSpeech = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popSegment(): VadSegment? {
|
||||||
|
if (queue.isEmpty()) return null
|
||||||
|
return queue.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun flush() {
|
||||||
|
if (inSpeech && lastSpeechEnd - segStart >= 4000) {
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { vad.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Готовый сегмент: срез из общего буфера [segStart, lastSpeechEnd). */
|
||||||
|
private fun emit() {
|
||||||
|
val len = lastSpeechEnd - segStart
|
||||||
|
val seg = VadSegment(segStart, FloatArray(len) { buf[segStart + it] })
|
||||||
|
queue.addLast(seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Чистый» тест распознавания: реальный Silero VAD (ONNX через vad-jvm) +
|
||||||
|
* реальная аудио-фикстура voice-samples/masha-anton.wav.
|
||||||
|
* Без pw.binom.viewmate в classpath — только библиотека VAD и тестовый код.
|
||||||
|
*/
|
||||||
|
class SileroVadRealAudioTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun segmentsOnRealAudio() {
|
||||||
|
val vad = SileroStreamingVad()
|
||||||
|
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||||
|
val n = pcm.size / 2 // сэмплов 16 кГц
|
||||||
|
assertTrue(n >= 512, "PCM фикстуры пуст")
|
||||||
|
|
||||||
|
// Весь PCM → окнами по 512 (хвост < 512 дополняем нулями — тишиной).
|
||||||
|
val total = ((n + 511) / 512) * 512 // обработано сэмплов на VAD
|
||||||
|
val segments = mutableListOf<VadSegment>()
|
||||||
|
for (i in 0 until total step 512) {
|
||||||
|
val window = FloatArray(512)
|
||||||
|
for (j in 0 until 512) {
|
||||||
|
val idx = i + j
|
||||||
|
if (idx < n) {
|
||||||
|
val lo = pcm[idx * 2].toInt() and 0xff
|
||||||
|
val hi = pcm[idx * 2 + 1].toInt() and 0xff
|
||||||
|
var s = lo or (hi shl 8)
|
||||||
|
if (s >= 0x8000) s -= 0x10000
|
||||||
|
window[j] = s / 32768f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vad.acceptWindow(window)
|
||||||
|
var seg = vad.popSegment()
|
||||||
|
while (seg != null) {
|
||||||
|
segments.add(seg)
|
||||||
|
seg = vad.popSegment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(segments.size >= 1, "сегментов не найдено (VAD не нашёл речь в 60-секундной фикстуре)")
|
||||||
|
var speechSamples = 0
|
||||||
|
for (seg in segments) {
|
||||||
|
assertTrue(seg.startSample >= 0, "start < 0 в сегменте ${seg.startSample}")
|
||||||
|
assertTrue(
|
||||||
|
seg.startSample + seg.samples.size <= total,
|
||||||
|
"сегмент выходит за конец потока: start=${seg.startSample}, len=${seg.samples.size}, total=$total",
|
||||||
|
)
|
||||||
|
speechSamples += seg.samples.size
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
speechSamples in 5000..500_000,
|
||||||
|
"суммарная речь ${speechSamples} сэмплов вне ожидаемого [5000..500000] " +
|
||||||
|
"(${speechSamples / 16000.0}s вне 0.3–31с), сегментов=${segments.size}",
|
||||||
|
)
|
||||||
|
|
||||||
|
vad.flush()
|
||||||
|
assertTrue(vad.popSegment() == null, "после flush() остались сегменты")
|
||||||
|
vad.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
|
||||||
|
* внутренние паузы сохраняются, отступ не выходит за границы.
|
||||||
|
*
|
||||||
|
* Окно 320 сэмплов = 20 мс @ 16 кГц; отступ [padWindows]=15 → 4800 сэмплов = 300 мс.
|
||||||
|
* «Речь» — сэмпл 0.5f (RMS окна 0.5 ≥ порог 0.01), «тишина» — 0.005f (ниже порога).
|
||||||
|
*/
|
||||||
|
class TrimEdgesTest {
|
||||||
|
private val win = 320
|
||||||
|
private val pad = 15 * 320
|
||||||
|
|
||||||
|
/** Массив из [count] сэмплов заданного уровня. */
|
||||||
|
private fun fill(count: Int, level: Float): FloatArray = FloatArray(count) { level }
|
||||||
|
|
||||||
|
private fun concat(vararg parts: FloatArray): FloatArray {
|
||||||
|
var total = 0
|
||||||
|
for (p in parts) total += p.size
|
||||||
|
val out = FloatArray(total)
|
||||||
|
var i = 0
|
||||||
|
for (p in parts) {
|
||||||
|
p.copyInto(out, i)
|
||||||
|
i += p.size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Краевая тишина (100 окон до и после речи в 3 окна) убирается, речь сохраняется. */
|
||||||
|
@Test
|
||||||
|
fun edgeSilenceIsTrimmed() {
|
||||||
|
val lead = fill(100 * win, 0.005f)
|
||||||
|
val voice = fill(3 * win, 0.5f)
|
||||||
|
val tail = fill(100 * win, 0.005f)
|
||||||
|
val samples = concat(lead, voice, tail)
|
||||||
|
|
||||||
|
val res = trimEdges(samples)
|
||||||
|
|
||||||
|
val start = 100 * win - pad
|
||||||
|
val stop = 103 * win + pad
|
||||||
|
assertContentEquals(samples.copyOfRange(start, stop), res, "краевая тишина обрезана, речь сохранена")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Внутренняя пауза (197 окон тишины между двумя речевыми блоками) полностью сохраняется. */
|
||||||
|
@Test
|
||||||
|
fun internalPauseIsPreserved() {
|
||||||
|
val lead = fill(100 * win, 0.005f)
|
||||||
|
val voice1 = fill(3 * win, 0.5f)
|
||||||
|
val pause = fill(197 * win, 0.005f)
|
||||||
|
val voice2 = fill(3 * win, 0.5f)
|
||||||
|
val tail = fill(100 * win, 0.005f)
|
||||||
|
val samples = concat(lead, voice1, pause, voice2, tail)
|
||||||
|
|
||||||
|
val res = trimEdges(samples)
|
||||||
|
|
||||||
|
val start = 100 * win - pad
|
||||||
|
val stop = 303 * win + pad
|
||||||
|
assertContentEquals(samples.copyOfRange(start, stop), res, "внутренняя пауза не выброшена")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустой вход → пустой выход (без ошибок). */
|
||||||
|
@Test
|
||||||
|
fun emptyInputReturnsEmpty() {
|
||||||
|
assertEquals(0, trimEdges(FloatArray(0)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всё-тишина (ниже порога) → пустой массив. */
|
||||||
|
@Test
|
||||||
|
fun allSilenceReturnsEmpty() {
|
||||||
|
val res = trimEdges(FloatArray(100 * win) { 0.005f })
|
||||||
|
assertEquals(0, res.size, "звучных окон нет — результат пуст")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отступ не выходит за границы: речь у краёв короткого буфера → весь массив без исключений. */
|
||||||
|
@Test
|
||||||
|
fun paddingClampsToBounds() {
|
||||||
|
val size = 2000
|
||||||
|
val samples = FloatArray(size)
|
||||||
|
for (i in 0 until win) samples[i] = 0.5f
|
||||||
|
for (i in size - win until size) samples[i] = 0.5f
|
||||||
|
|
||||||
|
val res = trimEdges(samples)
|
||||||
|
|
||||||
|
assertContentEquals(samples.copyOfRange(0, size), res, "clamp к границам, без выхода за массив")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный загрузчик RIFF/WAVE для тестов: s16le, 16 кГц, mono.
|
||||||
|
* (Свой, а не WavReader из main — тот отдаёт FloatArray, а нужно как раз
|
||||||
|
* сырые s16le-байты PCM.)
|
||||||
|
*/
|
||||||
|
object WavPcm {
|
||||||
|
|
||||||
|
/** Абсолютный путь к фикстуре voice-samples/masha-anton.wav (корень репо). */
|
||||||
|
fun fixture(): File {
|
||||||
|
// working dir юнит-теста gradle — каталог модуля (app-phone) или корень;
|
||||||
|
// ищем voice-samples/ вверх от user.dir.
|
||||||
|
var dir = File(System.getProperty("user.dir")).absoluteFile
|
||||||
|
while (true) {
|
||||||
|
val cand = File(dir, "voice-samples/masha-anton.wav")
|
||||||
|
if (cand.isFile) return cand
|
||||||
|
dir = dir.parentFile ?: break
|
||||||
|
}
|
||||||
|
return File("voice-samples/masha-anton.wav").absoluteFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PCM s16le, 16 кГц, mono. Если формат не тот — fail(). */
|
||||||
|
fun loadPcm(path: String): ByteArray {
|
||||||
|
val f = File(path)
|
||||||
|
check(f.isFile) { "WAV не найден: $path" }
|
||||||
|
val b = f.readBytes()
|
||||||
|
check(b.size > 44) { "WAV: файл короче заголовка" }
|
||||||
|
fun str(off: Int, len: Int) = String(b.copyOfRange(off, off + len))
|
||||||
|
check(str(0, 4) == "RIFF") { "WAV: не RIFF" }
|
||||||
|
check(str(8, 4) == "WAVE") { "WAV: не WAVE" }
|
||||||
|
fun i16(off: Int) = ByteBuffer.wrap(b, off, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
|
||||||
|
fun i32(off: Int) = ByteBuffer.wrap(b, off, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
||||||
|
|
||||||
|
var off = 12
|
||||||
|
var audioFormat = 0
|
||||||
|
var channels = 0
|
||||||
|
var sampleRate = 0
|
||||||
|
var bitsPerSample = 0
|
||||||
|
var dataOff = -1
|
||||||
|
var dataLen = 0
|
||||||
|
while (off + 8 <= b.size) {
|
||||||
|
val id = str(off, 4)
|
||||||
|
val len = i32(off + 4)
|
||||||
|
when (id) {
|
||||||
|
"fmt " -> {
|
||||||
|
audioFormat = i16(off + 8)
|
||||||
|
channels = i16(off + 10)
|
||||||
|
sampleRate = i32(off + 12)
|
||||||
|
bitsPerSample = i16(off + 22)
|
||||||
|
}
|
||||||
|
"data" -> {
|
||||||
|
dataOff = off + 8
|
||||||
|
dataLen = len
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off += 8 + len + (len and 1) // чанки выравниваются по чётным байтам
|
||||||
|
}
|
||||||
|
check(audioFormat == 1) { "WAV: не PCM (format=$audioFormat)" }
|
||||||
|
check(channels == 1) { "WAV: каналов=$channels, ожидался mono" }
|
||||||
|
check(sampleRate == 16000) { "WAV: частота=$sampleRate, ожидалась 16000" }
|
||||||
|
check(bitsPerSample == 16) { "WAV: битов/сэмпл=$bitsPerSample, ожидалось 16" }
|
||||||
|
require(dataOff >= 0) { "WAV: нет чанка data" }
|
||||||
|
val end = minOf(b.size, dataOff + dataLen)
|
||||||
|
return b.copyOfRange(dataOff, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Жесты и режимы очков (design, согласовано 2026-08-22)
|
||||||
|
|
||||||
|
> Согласовано с пользователем. Реализация — opencode по TASK (см. ниже).
|
||||||
|
> Это документ проекта: сначала думаем, потом хуячим, потом тесты, потом ОБЯЗАТЕЛЬНО мануальные тесты на железе.
|
||||||
|
|
||||||
|
## Ограничения системы (факты)
|
||||||
|
|
||||||
|
- Жестов всего шесть: Click, DoubleClick, TripleClick, SlideForward, SlideBackward, LongClick (+ SlideContinuous с delta).
|
||||||
|
- **LongClick НЕ работает** — перехвачен системным ассистентом RayNeo. Не рассчитывать, не использовать.
|
||||||
|
- **SlideContinuous существует** (SDK: `TempleAction.SlideContinuous(delta: Float, ...)` — подтверждено javap) — плавный скролл возможен, delta = дельту движения пальца. Частоту событий и амплитуду дельт проверить на железе.
|
||||||
|
- Экран очков — широкий 1280×480 (640×480 на глаз). Текста влезает мало → скролл ответа обязателен для длинных ответов.
|
||||||
|
- Оверлей общения затемняет кино (72% чёрного) → режим общения ЯВНЫЙ: пользователь видит, что он в чате, а не в кино. Это позволяет переназначать свайпы в режиме общения без конфликта.
|
||||||
|
- Нет клавиатуры — только жесты и голос. Выход из режима — только жестом.
|
||||||
|
- LLM думает 5-15с — нужен индикатор «думаю...», иначе экран замирает.
|
||||||
|
|
||||||
|
## Общий паттерн жестов (единая семантика через все режимы)
|
||||||
|
|
||||||
|
| Жест | Смысл везде |
|
||||||
|
|---|---|
|
||||||
|
| **TripleClick** | вызов локального ассистента (переход в режим общения) |
|
||||||
|
| **DoubleClick** | отмена / назад |
|
||||||
|
| **Click** | основное действие текущего контекста |
|
||||||
|
|
||||||
|
## Матрица жестов по состояниям (ЗАКРЫТА)
|
||||||
|
|
||||||
|
| Состояние | Click | DoubleClick | TripleClick |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Кино** | плей/пауза | игнор (назад некуда) | вызов ассистента (пауза + «слушаю...») |
|
||||||
|
| **Прослушивание** | отправить фразу | отмена, **остаёмся в диалоге** | игнор |
|
||||||
|
| **Ответ виден** | новая фраза (микрофон открывается) | **выход из режима** | игнор (уже в режиме) |
|
||||||
|
|
||||||
|
Ключевой принцип: **клик = «главное действие здесь»**. В кино — плей/пауза, в диалоге — сказать новую фразу, при прослушивании — отправить. Один жест, контекст решает. Двойной — всегда «не хочу/назад». Тройной — всегда «хочу говорить с ассистентом».
|
||||||
|
|
||||||
|
Конфликтов нет: подрежимы различимы по состоянию (listening = микрофон открыт / assistantText непуст = ответ виден).
|
||||||
|
|
||||||
|
## Решения по деталям
|
||||||
|
|
||||||
|
1. **Отмена диктовки (DoubleClick в прослушивании)** — просто отмена, остаёмся в диалоге (назад к «ответу виден», ничего не теряется).
|
||||||
|
2. **Фильм после выхода (DoubleClick при ответе)** — ВСЕГДА на паузе, независимо от того, был ли он на паузе до входа в режим ассистента.
|
||||||
|
3. **Скролл ответа** — плавный: SlideContinuous, отслеживать движение пальца (delta), а не факт свайпа с шагом N. Свайпы SlideForward/SlideBackward в режиме общения — тоже скролл (запасной вариант, если SlideContinuous окажется нестабильным на железе).
|
||||||
|
4. **Новая фраза в режиме «ответ виден»** — Click: микрофон открывается, «слушаю...». Старый ответ с экрана — стереть (история живёт в SQLite на телефоне, не теряется).
|
||||||
|
5. **Индикатор «думаю...»** — после отправки фразы (между прослушиванием и ответом) показывать «думаю...» вместо замирания на тексте фразы.
|
||||||
|
|
||||||
|
## Что делать (TASK для opencode)
|
||||||
|
|
||||||
|
В `app-glasses` (MainActivity.kt + оверлей):
|
||||||
|
|
||||||
|
1. **Переразметка жестов по матрице**:
|
||||||
|
- Кино: как сейчас (Click=TOGGLE, TripleClick=вызов, свайпы=±30с, DoubleClick=игнор)
|
||||||
|
- Прослушивание: как сейчас (Click=отправить, DoubleClick=отмена, TripleClick=игнор)
|
||||||
|
- Ответ виден (listening=false, assistantText непуст): **Click → новая фраза** (открыть микрофон, стереть assistantText со старта), **DoubleClick → выход из режима** (закрыть оверлей, фильм на паузе, assistantText стереть), **TripleClick → игнор**, свайпы/SlideContinuous → скролл
|
||||||
|
2. **Скролл ответа**: плавный через SlideContinuous (delta), scrollState для текста ответа; запасной вариант — дискретные свайпы. Ограничить скролл границами текста.
|
||||||
|
3. **Индикатор «думаю...»**: состояние между отправкой фразы и получением ShowText; показывать в оверлее.
|
||||||
|
4. **Выход**: DoubleClick при «ответ виден» — закрыть оверлей, фильм остаётся на паузе (не трогать play/pause при выходе, только при входе ставим паузу).
|
||||||
|
|
||||||
|
Тесты: юнит-тесты на чистую логику разметки жестов (переходы состояний, скролл-дельта → смещение), в духе GestureCommandsTest. Мануальные тесты — ОБЯЗАТЕЛЬНО на железе (142: очки 7687f645 + телефон AXGL024B05001337): полный цикл кино → тройной тап → фраза → «думаю...» → ответ → скролл → выход → фильм на паузе.
|
||||||
|
|
||||||
|
## Смежные договорённости (архитектура агента)
|
||||||
|
|
||||||
|
Полная архитектура скиллов/тулсетов — в `TASK-agent-tools.md`. Ключевое для очков: ассистент — локальный, на телефоне; очки — тонкий клиент (микрофон → STT → телефон → LLM → ShowText на очки). Режим общения — поверх любого режима (кино/чат), активируется тройным тапом из любого места.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# OpenAssistant (vayun-mathur/Modern-Apps) — локальные нейронки на Android
|
||||||
|
|
||||||
|
Источник: https://github.com/vayun-mathur/Modern-Apps/tree/main/openassistant
|
||||||
|
Клон: ~/WORK/modern-apps/ (depth 1). Разбор 2026-08-10.
|
||||||
|
|
||||||
|
## Текстовые альтернативы E2B в формате .litertlm (проверено 2026-08-10, полка litert-community)
|
||||||
|
Вопрос: есть ли ТЕКСТОВЫЕ модели заметно умнее E2B (2B) за те же/чуть большие токены, тот же формат.
|
||||||
|
|
||||||
|
| Модель | Парам. | Контекст | Размер | Декод на 8 Gen 3 | Оценка |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Gemma 4 E2B (текущая) | 2B | 32K | ~0.8 ГБ | 20-35 т/с (спекуляция вкл) | база сравнения |
|
||||||
|
| Qwen3-8B (mixed int4) | 8B | 2048 (!) | 4.66 ГБ | ~10-12 т/с (оценка, телеф. бенча нет) | заметно умнее, но контекст урезан вдрызг |
|
||||||
|
| Phi-4-mini-instruct | 3.8B | 4096 | 3.9 ГБ | 10.39 т/с GPU (S24 Ultra, 8 Gen 3, динамик int8) | умнее, в 2 раза медленнее |
|
||||||
|
| Ministral-3-3B-Reasoning | 3.3B | 4096 | ~2.2 ГБ | нет телеф. бенча | reasoning, текст, но медленный по природе |
|
||||||
|
| DeepSeek-R1-Distill-Qwen-1.5B | 1.5B | 4096 | 1.83 ГБ | 26.35 т/с CPU (S24 Ultra) | не умнее, другой класс (reasoning) |
|
||||||
|
| Gemma2-2B-IT | 2B | — | — | — | доступ ограничен (restricted) |
|
||||||
|
| Gemma3-4B-IT | 4B | — | — | — | мультимодальная, не подходит по критерию |
|
||||||
|
|
||||||
|
ВЫВОД: за "умнее + текст" платишь скоростью и контекстом. Phi-4-mini — единственный с честным замером на 8 Gen 3 (10.39 т/с — вдвое медленнее 20-25). Qwen3-8B — умнее всех, но 2048 контекст и ~10 т/с. E2B остаётся оптимумом для "незаметной" скорости (20+ т/с).
|
||||||
|
|
||||||
|
|
||||||
|
## Две нейронки + токенайзер
|
||||||
|
|
||||||
|
### 1. Чат-LLM: Gemma 4 E2B (2B) — файл gemma-4-E2B-it.litertlm
|
||||||
|
- Формат .litertlm (LiteRT LM, Google; бывш. AI Edge / MediaPipe LLM Inference)
|
||||||
|
- Библиотека: com.google.ai.edge.litertlm:litertlm-android:0.14.0
|
||||||
|
- Запуск: Engine(EngineConfig(modelPath, backend=GPU(), visionBackend=GPU(), audioBackend=CPU(), cacheDir)) + engine.initialize()
|
||||||
|
- Speculative decoding: ExperimentalFlags.enableSpeculativeDecoding = true
|
||||||
|
- Мультимодальный ввод: Content.Text / Content.ImageFile / Content.AudioFile (голос — WavRecorder, обрабатывается на CPU)
|
||||||
|
- Источник: huggingface.co/litert-community/gemma-4-E2B-it-litert-lm
|
||||||
|
- SHA256: 181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c
|
||||||
|
- На диске: gemma4-2b.litertlm в getExternalFilesDir(null)
|
||||||
|
|
||||||
|
### 2. Эмбеддер SigLIP2-base (patch16, 224) — семантический поиск по фото
|
||||||
|
- Служит фото-приложению через ResultReceiver (IPC): text/image/info-запросы
|
||||||
|
- ONNX Runtime: com.microsoft.onnxruntime:onnxruntime-android:1.27.0
|
||||||
|
- Два файла: vision_model_fp16.onnx (картинки), text_model_int8.onnx (текст)
|
||||||
|
- Вектора 768-мерные (dim читается из модели), L2-нормированные, косинус
|
||||||
|
- Источник: huggingface.co/onnx-community/siglip2-base-patch16-224-ONNX
|
||||||
|
- SHA256 vision: a1959f7bd3993a607e48839f6d01e25b876fe76afda301b028b78eef68aabd95
|
||||||
|
- SHA256 text: 3a0603d3a00c05a80a6ded4743c16aaac7b1e62cdcc7e362e7ce418659b96400
|
||||||
|
- ВАЖНО: из выходов брать pooler_output (ранг-2), НЕ last_hidden_state (ранг-3), иначе вектора бессмысленны
|
||||||
|
- Препроцессинг картинки: ресайз прямо в 224x224 (без center-crop), (px-127.5)/127.5, NCHW RGB
|
||||||
|
- Сессии ONNX однопоточные (setIntraOpNumThreads(1)) — бережёт батарею
|
||||||
|
|
||||||
|
### 3. Токенайзер SentencePiece (Gemma tokenizer.model, ~4MB, 256k vocab)
|
||||||
|
- Файл: siglip2_tokenizer.model (SHA256 61a7b147390c64585d6c3543dd6fc636906c9af3865a5548f27f31aee1d4c8e2)
|
||||||
|
- Нужен ТОЛЬКО текстовой башне SigLIP2 (litertlm не отдаёт token id наружу)
|
||||||
|
- Реализация без protobuf-зависимости: ручной парсинг ModelProto (pieces = field 1; piece=1 string, score=2 float, type=3 enum; индекс == token id)
|
||||||
|
- Кодирование: Viterbi Unigram сегментация + byte fallback (<0xNN>, Gemma шлёт все 256 байтовых писов)
|
||||||
|
- Препроцессинг текста: lowercase → NFKC → схлопнуть пробелы → dummy-prefix пробел → ▁ (U+2581)
|
||||||
|
- SEQ_LEN = 64 (падинг padId=0, обрезка)
|
||||||
|
- Риск: совпадение с transformers Siglip2Processor — первое, что проверять при повторе
|
||||||
|
|
||||||
|
## Как это работает (архитектура)
|
||||||
|
|
||||||
|
- Foreground-сервис InferenceService, 3 очереди: standard (чат), intent (JSON-извлечение), embedding (SigLIP2 — отдельный consumer, не ждёт LLM)
|
||||||
|
- Модели качаются ТОЛЬКО с зеркала https://data.vayunmathur.com/models/ (никакого HF-фолбэка, supply-chain митигация), SHA-256 проверка, Content-Length + ETag
|
||||||
|
- Чат: история из Room-БД → ConversationConfig(systemInstruction, initialMessages, tools, automaticToolCalling=true) → sendMessageAsync стриминг
|
||||||
|
- Инструменты (AssistantToolSet): add_to_memory / get_memories / remove_memory и др., automaticToolCalling=true
|
||||||
|
- Intent-режим: system prompt "data extraction engine", стриминг, tryExtractLargestJson + валидация по JSON Schema, досрочный HALT через CancellationException("HALT"), таймаут 45с, очередь-дроп по 45с
|
||||||
|
- Версионирование: cleanupStaleSiglipModels по SiglipEmbedder.MODEL_VERSION; legacy gemma4.litertlm / gemma4-4b.litertlm удаляются при старте
|
||||||
|
- Сборка: noCompress += "onnx"; packaging pickFirsts: libLiteRtTopKOpenClSampler.so, libc++_shared.so
|
||||||
|
|
||||||
|
## Ключевой вывод (что ценно для нас)
|
||||||
|
|
||||||
|
Главный урок не в архитектуре, а в скорости: автор довёл локальную нейронку на телефоне до >=20-25 ток/с — пользователь не видит задержки, ассистент отвечает "на скидку" (вживую, без заметных пауз). Это рецепт быстрого запуска нейронки на устройстве:
|
||||||
|
- Модель 2B (Gemma 4 E2B) в оптимизированном формате LiteRT LM — компактная, но живая
|
||||||
|
- GPU-бэкенд для text/vision (OpenCL), CPU только для аудио
|
||||||
|
- Speculative decoding включён (ExperimentalFlags) — дёшево ускоряет генерацию
|
||||||
|
- Одна сессия движка, переиспользуется; очереди разнесены, эмбеддинги не блокируют чат
|
||||||
|
- Токенайзер ручной (без protobuf), эмбеддер на ONNX — но это обвязка, не узкое место
|
||||||
|
|
||||||
|
Т.е. заявка на повтор: 2B-модель + LiteRT LM + GPU + speculative decoding = быстрый локальный ассистент.
|
||||||
|
|
||||||
|
## Ответы на вопросы (2026-08-10)
|
||||||
|
|
||||||
|
### Что за формат .litertlm и можно ли заменить модель
|
||||||
|
- .litertlm — контейнер LiteRT-LM (Google; бывш. MediaPipe LLM Inference / AI Edge): внутри квантованная модель (смесь 2/4/8 бит, text-only вес ~0.8GB), эмбеддинги (1.12GB, memory-mapped), токенайзер, плюс движок даёт KV-cache менеджмент, промпт-темплейт, function calling
|
||||||
|
- ЗАМЕНИТЬ МОЖНО, и это просто: готовые .litertlm в litert-community на HF (десятки): Qwen3 0.6B/1.7B/8B, Qwen2.5 0.5B/1.5B, TinyLlama-1.1B, Phi-4-mini, SmolLM-135M, DeepSeek-R1-Distill-Qwen-1.5B (reasoning), Gemma3-1B/4B, FunctionGemma-270M (function calling), Nanbeige-3B, Ministral-3-3B, Mage-VL (VLM), FastVLM-0.5B. Свои модели — через LiteRT Torch Generative API (ai-edge-litert, Python)
|
||||||
|
- В openassistant замена = поменять URL+SHA256 в ModelUrls и файл. SigLIP2-токенайзер от LLM не зависит
|
||||||
|
- Ограничения при замене: (а) speculative decoding вшит в сборку модели — у Gemma 4 E2B есть, у других не факт; (б) Content.ImageFile/AudioFile работают только у мультимодальных сборок; (в) tools/automaticToolCalling — у Gemma/Qwen/Phi есть, не у всех
|
||||||
|
- Для скорости на слабом железе: Qwen3-0.6B / SmolLM-135M / Qwen2.5-0.5B (быстрее, но глупее); компромисс: Qwen3-1.7B, Gemma3-1B; русский текст у Qwen обычно лучше
|
||||||
|
|
||||||
|
### Мультимодальность Gemma 4 E2B
|
||||||
|
- ДА: текст + картинки + аудио в одном сообщении. В коде: Content.Text / Content.ImageFile / Content.AudioFile; text/vision на GPU, audio на CPU
|
||||||
|
- Из README: картинки/аудио класть ПЕРЕД текстом в промпте; переменное разрешение картинок через visual token budget (токенов на картинку); vision/audio части грузятся по требованию
|
||||||
|
- В openassistant intent-режим шлёт imagePaths + userText одним Messages.user — живой пример смешанного ввода
|
||||||
|
|
||||||
|
### Контекст
|
||||||
|
- Полная Gemma 4 — до 256K токенов (model card). On-device litertlm-сборка: "supports up to 32k context length" — 32K токенов
|
||||||
|
- Бенчмарки litert-community: 1024 prefill + 256 decode, контекст 2048; S26 Ultra GPU: decode 52 ток/с (baseline), 66-92 ток/с со speculative decoding; prefill 3808 ток/с; TTFT 0.3с
|
||||||
|
- Приложение лимита не задаёт (ConversationConfig без maxTokens) — контекстом рулит движок (KV-cache менеджмент); история из Room идёт в initialMessages, упор в 32k движок разруливает сам
|
||||||
|
- 20-25 ток/с автора — нижняя граница "незаметно", на флагмане официально 46-92
|
||||||
|
|
||||||
|
### Скорость на Snapdragon 8 Gen 3 (12 ГБ RAM)
|
||||||
|
- УСТРОЙСТВО АВТОРА (подтверждено 2026-08-10): Honor Magic V3, Snapdragon 8 Gen 3, 12 ГБ RAM. (Сначала назвал Gen 2 — ложный след, снят с протокола.)
|
||||||
|
- Официальная карточка litert-community НЕ даёт 8 Gen 3 — бенчмарки только на S26 Ultra (чип 2026, новее): GPU decode 52.1 ток/с baseline, 66.5-91.7 со speculative decoding; prefill 3808 ток/с; TTFT 0.3с; CPU: decode 46.9, prefill 557, TTFT 1.8с. Память: GPU-бэкенд 676 МБ, CPU 1733 МБ
|
||||||
|
- 8 Gen 3 (2023, Adreno 750, напр. S24 Ultra US): официальных цифр нет; сторонние замеры (MindStudio): E2B 20-35 ток/с, E4B 12-20 ток/с. Прим.: у MindStudio Pixel 9 приписан к 8 Gen 3 ошибочно (там Tensor G4); настоящий 8 Gen 3 — S24 Ultra (US) и другие
|
||||||
|
- Память 12 ГБ — с большим запасом: модель на GPU ~676-800 МБ RSS (text-only), эмбеддинги 1.12 ГБ memory-mapped (не в RSS), vision/audio по требованию. Ограничение не в RAM, а в скорости GPU/пропускной способности
|
||||||
|
- Вывод: заявленные автором 20-25 ток/с — реалистичная нижняя/средняя граница вилки для 8 Gen 3; со speculative decoding можно ждать заметно больше (порядка 50+ на флагмане, по аналогии с S26 Ultra)
|
||||||
|
|
||||||
|
### Спекулятивное декодирование (MTP) на Snapdragon 8 Gen 3
|
||||||
|
- Что это: LLM обычно выдаёт 1 токен за проход сети. Спекулятивное декодирование: маленький черновик-предсказатель набрасывает сразу N следующих токенов, большая модель проверяет их ОДНИМ проходом — если угадал, получил N токенов ценой одного прохода. Ускорение до ~3x без потери качества
|
||||||
|
- У Gemma 4 это НЕ внешняя надстройка, а встроенная архитектура — Multi-Token Prediction (MTP): черновик-головы сидят прямо в чекпоинте модели (ai.google.dev/gemma/docs/mtp). Спекуляция у Gemma 4 работает автоматически при поддержке рантайма; флаг лишь включает рантайм-путь
|
||||||
|
- В openassistant флаг ПОДТВЕРЖДЁН в коде: InferenceService.kt:486 — ExperimentalFlags.enableSpeculativeDecoding = true перед engine.initialize()
|
||||||
|
- ВЫВОД: 20-25 ток/с автора НЕ означают, что спекуляция выключена. Наоборот — она включена. У автора Honor Magic V3 = Snapdragon 8 Gen 3 (Adreno 750): вилка со спекуляцией ~20-35 ток/с (MindStudio), без неё ~10-15 (вдвое меньше). Его 20-25 — ровно середина ожидаемого С включённой спекуляцией, ближе к нижней границе (зависит от термальных лимитов складного корпуса)
|
||||||
|
- Как проверить на живом телефоне: поставить enableSpeculativeDecoding = false, замерить — падение вдвое = она работала
|
||||||
|
- 8 Gen 3 (2023, Adreno 750): сторонние замеры E2B 20-35 ток/с; на 8 Gen 2 (2022, Adreno 740) было бы ниже на ~15-20%
|
||||||
|
|
||||||
|
## Как повторить (чеклист)
|
||||||
|
|
||||||
|
1. Скачать 4 файла с HF (см. SHA256 выше), разложить под зеркало /models/
|
||||||
|
2. Android: litertlm-android 0.14.0 + onnxruntime-android 1.27.0 (+ kotlinx-coroutines 1.11.0 — иначе close$default на SendChannel)
|
||||||
|
3. Backend: GPU для text/vision, CPU для audio
|
||||||
|
4. Из SigLIP2 ONNX читать pooler_output, не last_hidden_state
|
||||||
|
|
||||||
|
## Бонус: что ещё в монорепо Modern-Apps
|
||||||
|
findfamily (UWB-локатор), keyboard (хангыль-композитор), maps, games (Stockfish через JitPack ncnn), библиотеки: downloadservice, room, image.
|
||||||
|
|
||||||
|
## Raspberry Pi 5: да, работает
|
||||||
|
|
||||||
|
Вопрос: потянет ли Gemma 4 E2B Raspberry Pi 5? Ответ: да, и это официально.
|
||||||
|
|
||||||
|
Формат и рантайм:
|
||||||
|
- LiteRT-LM теперь официально кросс-платформенный: Android, iOS, Web, Desktop, IoT (Raspberry Pi). Есть официальный CLI (uvx-установка), который гоняет .litertlm прямо на Pi — без конвертации. GitHub: google-ai-edge/LiteRT-LM.
|
||||||
|
- Google сам пишет «Try Gemma4-E4B with MTP on Linux, macOS, Windows or Raspberry Pi with the LiteRT-LM CLI».
|
||||||
|
|
||||||
|
Реальные замеры на RPi 5 (8 ГБ, 4×Cortex-A76 @ 2.4 ГГц, без GPU-акселератора):
|
||||||
|
1. Ollama (GGUF): первый токен ~3-4 сек, генерация ~8-12 t/s. Модель ~1.5 ГБ.
|
||||||
|
2. Стоковый llama.cpp (Q4): ~6.6 t/s.
|
||||||
|
3. mkturkcan/mote (оптимизированный llama.cpp + MTP-спекуляция + A76-ядра + KleidiAI): 11.2 t/s на обычном тексте, до 16 на шаблонном, до 20.4 в turbo на повторах (логи/CSV). Устанавливается одной командой, ничего не собирается на Pi.
|
||||||
|
4. Официальный LiteRT-LM CLI: цифры не подтверждены, но движок тот же — ожидаемо в районе 6-12 t/s.
|
||||||
|
|
||||||
|
Важно про контекст на Pi:
|
||||||
|
- Модель заявляет 128K контекста, но KV-кэш на 128K не влезает в 8 ГБ. На RPi 5 (8 ГБ) реально 4K-8K токенов, дальше — своп и обвал скорости. На 16 ГБ — комфортно ~32K.
|
||||||
|
- Мультимодальность (текст+картинки+видео) на Pi работает, но медленнее телефона.
|
||||||
|
|
||||||
|
Вывод: на RPi 5 E2B даёт ~8-12 t/s (сток) или ~11-20 t/s (оптимизированный mote). Это в 2-3 раза медленнее телефона (20-25 t/s), но для офлайн-ассистента, пакетной обработки и домашних экспериментов — достаточно. Воспринимается как «медленно, но читаемо», а не как «зависло».
|
||||||
|
|
||||||
|
## Батарея и скорость обработки картинок (Honor Magic V3, 8 Gen 3, 12 ГБ)
|
||||||
|
|
||||||
|
### Батарея
|
||||||
|
- Точных ватт на Android под GPU-нагрузкой НЕТ ни у кого: Battery Manager API Android ненадёжен под нагрузкой, iOS API не отдаёт (arxiv 2603.23640, "LLM Inference at the Edge", бенчмарк на S24 Ultra = тот же 8 Gen 3).
|
||||||
|
- Ориентиры из той статьи: S24 Ultra idle ~0.8 Вт, max ~12 Вт (система целиком). Qwen 1.5B через MLC-LLM: 9.93 ток/с sustained, префилл 25 сек (OpenCL-оверхед MLC), термальный фейл на 6-й итерации подряд: GPU freq упала до 231 МГц, 78.3°C — жёсткий флор от термогубернатора, а не плавный DVFS как у iPhone.
|
||||||
|
- Энергия на токен на edge-платформах: ~270-300 мДж/токен (Hailo-10H 1.87 Вт / 6.9 ток/с; RTX 4050 ноут 34 Вт / 131.7 ток/с).
|
||||||
|
- Honor Magic V3: батарея 5150 мАч ≈ 19.9 Вт·ч.
|
||||||
|
- Прикидка: Gemma 4 E2B на 8 Gen 3 ~22 ток/с при ~4-5 Вт системы → 0.2 Дж/токен ≈ 56 мкВт·ч/токен. Непрерывная генерация ≈ 4-4.5 часа на всю батарею (20-25%/час). Сценарий ассистента (5-10 мин генерации в час) → 1.5-4%/час — почти незаметно.
|
||||||
|
- Термальный капкан 8 Gen 3: непрерывная нагрузка без пауз → троттлинг, скорость падает до ~2 раз. Magic V3 тонкий — отвод тепла хуже, троттлинг раньше. Паузы между запросами обязательны (раз в несколько минут — ок).
|
||||||
|
- LiteRT-LM оптимизирован под Adreno лучше MLC-LLM (это гугловский движок), поэтому 20-25 ток/с у автора — уже с учётом реального троттлинга.
|
||||||
|
|
||||||
|
### Картинки (видео-поток с камеры)
|
||||||
|
- Gemma 4: переменное разрешение через токен-бюджет: 70/140/280/560/1120 токенов на картинку. Google прямо советует для кадров видео НИЗКИЙ бюджет (70) ради скорости. Механика: 9 патчей на токен (3x3 среднее).
|
||||||
|
- Каждый кадр = вижн-энкодер + префилл токенов бюджета. Порядок: 0.3-1.5 сек на кадр на 8 Gen 3 (оценка, точного бенчмарка нет).
|
||||||
|
- 30 fps НЕВОЗМОЖНО: 70 токенов/кадр × 30 = 2100 ток/с префилла — префилл на телефоне ~200-500 ток/с. Реалистично: кадр раз в 2-5 сек, бюджет 70-140.
|
||||||
|
- Контекст 32K: при 140 ток/кадр ≈ 230 кадров в окне, при 70 ≈ 450. Кадр раз в 2 сек → 8-15 минут непрерывного окна → нужен скользящий буфер кадров.
|
||||||
|
- Для "где я иду/еду": модель получает кадр + вопрос ("где я?"), отвечает за 1-3 сек. Рабочий вариант — периодический анализ, а не поток. Сколько картинок за сообщение принимает on-device сборка — проверять на практике (в доке: reasoning across multiple images).
|
||||||
|
- Код автора: Content.ImageFile(path) шлёт файлы как есть, бэкенд GPU для текста+зрения (InferenceService.kt:493-494), аудио CPU. Приложение лимит картинок не задаёт.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlinx-serialization = "1.8.1"
|
kotlinx-serialization = "1.8.1"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.11.0"
|
||||||
ktor = "3.3.0"
|
ktor = "3.3.0"
|
||||||
android = "8.9.2"
|
android = "8.9.2"
|
||||||
compose-plugin = "1.8.0"
|
compose-plugin = "1.8.0"
|
||||||
@@ -10,6 +10,7 @@ androidx-activity-compose = "1.10.1"
|
|||||||
exoplayer = "2.17.1"
|
exoplayer = "2.17.1"
|
||||||
media3 = "1.6.1"
|
media3 = "1.6.1"
|
||||||
koog = "1.1.1"
|
koog = "1.1.1"
|
||||||
|
litertlm-android = "0.14.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
@@ -23,6 +24,10 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
|
|||||||
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
||||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
|
|
||||||
|
# LiteRT LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
|
||||||
|
# litertlm-android 0.14.0 нужен на kotlinx-coroutines 1.11.0 (shared версия поднята)
|
||||||
|
litertlm-android = { module = "com.google.ai.edge.litertlm:litertlm-android", version.ref = "litertlm-android" }
|
||||||
|
|
||||||
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||||
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
|
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
|
||||||
# отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.
|
# отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ data class JellyfinItem(
|
|||||||
val Id: String,
|
val Id: String,
|
||||||
val Name: String? = null,
|
val Name: String? = null,
|
||||||
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
|
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
|
||||||
|
/** Год релиза (Movie/Series); в ответах может отсутствовать. */
|
||||||
|
val ProductionYear: Int? = null,
|
||||||
val Overview: String? = null,
|
val Overview: String? = null,
|
||||||
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
|
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
|
||||||
val SeriesName: String? = null, // для эпизодов
|
val SeriesName: String? = null, // для эпизодов
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package pw.binom.viewmate.core.net
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Константы Bluetooth-транспорта (SPP/RFCOMM) — TASK-transport.md п.3/п.4.
|
||||||
|
* Общие для стороны очков (клиент) и телефона (сервер): UUID-сервиса SPP,
|
||||||
|
* имя сервиса, разделитель строк-сообщений, период реконнекта.
|
||||||
|
*/
|
||||||
|
object BtTransportConstants {
|
||||||
|
/** UUID SPP-сервиса (Serial Port Profile, стандартный Bluetooth SIG). */
|
||||||
|
const val SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
|
||||||
|
|
||||||
|
/** Имя SPP-сервиса (RFCOMM-заголовок + человекочитаемый лог). */
|
||||||
|
const val SERVICE_NAME = "viewmate-spp"
|
||||||
|
|
||||||
|
/** Разделитель сообщений (JSON + перевод строки) в SPP. */
|
||||||
|
const val NEWLINE: Char = '\n'
|
||||||
|
|
||||||
|
/** Задержка (мс) между попытками реконнекta BT (оба конца — одинаковый цикл). */
|
||||||
|
const val RETRY_DELAY_MS: Long = 3_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package pw.binom.viewmate.core.net
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Транспорт связи очки↔телефон, сторона очков («клиент») — TASK-transport.md п.1.
|
||||||
|
* Сообщения ходят **JSON-строками**: декод/кодинг конвертов [HostToGlasses]/
|
||||||
|
* [GlassesToHost] делает вызывающий (HostConnection на очках, GlassesHub на
|
||||||
|
* телефоне) — транспорт остаётся независимым от протокола.
|
||||||
|
*
|
||||||
|
* Реализации: [WifiGlassesTransport] (WebSocket, дефолт) и BtGlassesTransport
|
||||||
|
* (Bluetooth SPP, флагом).
|
||||||
|
*/
|
||||||
|
interface GlassesTransport {
|
||||||
|
/** Название транспорта: `wifi` / `bluetooth`. */
|
||||||
|
val name: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключиться к телефону: бесконечный цикл реконнекта (ретрай через 3с при
|
||||||
|
* обрыве) живёт внутри реализации, в корутине вызывающего.
|
||||||
|
*
|
||||||
|
* @param onMessage входящее JSON-сообщение (конверт HostToGlasses, строкой).
|
||||||
|
*/
|
||||||
|
suspend fun connect(
|
||||||
|
onMessage: suspend (String) -> Unit,
|
||||||
|
onConnected: suspend () -> Unit,
|
||||||
|
onDisconnected: suspend () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Отправить JSON-сообщение (конверт GlassesToHost) на телефон. Без активного соединения — no-op. */
|
||||||
|
suspend fun send(json: String)
|
||||||
|
|
||||||
|
/** Закрыть транспорт: остановить цикл реконнекта и активное соединение. */
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Транспорт приёма соединений, сторона телефона («сервер») — TASK-transport.md п.1.
|
||||||
|
* [accept] выполняется бесконечно: принимает подключения очков и отдаёт события
|
||||||
|
* по строковым [connId] — не по объектам сокета/сессии (требование ТЗ:
|
||||||
|
* «сессии — connId-строки»). Сообщения — JSON-строки, декод — не в его
|
||||||
|
* заботе (запускающий: GlassesHub).
|
||||||
|
*/
|
||||||
|
interface GlassesServerTransport {
|
||||||
|
/** Название транспорта: `wifi` / `bluetooth`. */
|
||||||
|
val name: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бесконечно принимать подключения очков (в корутине вызывающего).
|
||||||
|
* @param onMessage входящее JSON-сообщение (конверт GlassesToHost) из соединения [connId].
|
||||||
|
* @param onConnected установлено новое соединение [connId].
|
||||||
|
* @param onDisconnected соединение [connId] оборвано.
|
||||||
|
*/
|
||||||
|
suspend fun accept(
|
||||||
|
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||||
|
onConnected: (connId: String) -> Unit,
|
||||||
|
onDisconnected: (connId: String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Отправить JSON-сообщение конкретному соединению [connId]. Соединения нет — no-op. */
|
||||||
|
suspend fun send(connId: String, json: String)
|
||||||
|
|
||||||
|
/** Закрыть транспорт: перестать принимать и закрыть активные соединения. */
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
@@ -10,23 +10,21 @@ import io.ktor.websocket.send
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import pw.binom.viewmate.core.log
|
import pw.binom.viewmate.core.log
|
||||||
import pw.binom.viewmate.core.media.defaultWsClient
|
import pw.binom.viewmate.core.media.defaultWsClient
|
||||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
|
||||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
|
||||||
import pw.binom.viewmate.core.protocol.protocolJson
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
|
* WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
|
||||||
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
|
* (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
|
||||||
* реконнектом через 3с при обрыве.
|
* бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
|
||||||
|
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
|
||||||
*/
|
*/
|
||||||
class GlassesWsClient(
|
class GlassesWsClient(
|
||||||
private val url: String, // ws://<host>:8080/ws/glasses
|
private val url: String, // ws://<host>:8080/ws/glasses
|
||||||
private val http: HttpClient = defaultWsClient(),
|
private val http: HttpClient = defaultWsClient(),
|
||||||
) {
|
) : GlassesTransport {
|
||||||
|
|
||||||
|
override val name = "wifi"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var closed = false
|
private var closed = false
|
||||||
@@ -36,13 +34,13 @@ class GlassesWsClient(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Подключиться и слушать. Возвращается при вызове [close].
|
* Подключиться и слушать. Возвращается при вызове [close].
|
||||||
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
|
* [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
|
||||||
* события (пере)подключения (для Hello и смены статуса в UI).
|
* события (пере)подключения (для Hello и смены статуса в UI).
|
||||||
*/
|
*/
|
||||||
suspend fun connect(
|
override suspend fun connect(
|
||||||
onHostMessage: suspend (HostToGlasses) -> Unit,
|
onMessage: suspend (String) -> Unit,
|
||||||
onConnected: suspend () -> Unit = {},
|
onConnected: suspend () -> Unit,
|
||||||
onDisconnected: suspend () -> Unit = {},
|
onDisconnected: suspend () -> Unit,
|
||||||
) {
|
) {
|
||||||
while (!closed) {
|
while (!closed) {
|
||||||
try {
|
try {
|
||||||
@@ -52,9 +50,7 @@ class GlassesWsClient(
|
|||||||
try {
|
try {
|
||||||
for (frame in ws.incoming) {
|
for (frame in ws.incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val text = frame.readText()
|
onMessage(frame.readText())
|
||||||
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
|
||||||
onHostMessage(msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -72,14 +68,13 @@ class GlassesWsClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отправить сообщение хосту. Без активного соединения — no-op. */
|
/** Отправить JSON-строку хосту. Без активного соединения — no-op. */
|
||||||
suspend fun send(msg: GlassesToHost) {
|
override suspend fun send(json: String) {
|
||||||
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
|
session?.send(json)
|
||||||
session?.send(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
||||||
fun close() {
|
override fun close() {
|
||||||
closed = true
|
closed = true
|
||||||
session?.cancel()
|
session?.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package pw.binom.viewmate.core.net
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import pw.binom.viewmate.core.media.defaultWsClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WiFi-транспорт, сторона очков («клиент») — TASK-transport.md п.2:
|
||||||
|
* [GlassesTransport] поверх [GlassesWsClient] — обёртка, логика цикла
|
||||||
|
* подключения/реконнекта не переписывается (переносится в интерфейс).
|
||||||
|
* Адрес (mDNS/scan/fallback — HostConnection) передаётся в URL.
|
||||||
|
*/
|
||||||
|
class WifiGlassesTransport(
|
||||||
|
url: String,
|
||||||
|
http: HttpClient = defaultWsClient(),
|
||||||
|
) : GlassesTransport {
|
||||||
|
|
||||||
|
override val name = "wifi"
|
||||||
|
|
||||||
|
private val client = GlassesWsClient(url, http)
|
||||||
|
|
||||||
|
override suspend fun connect(
|
||||||
|
onMessage: suspend (String) -> Unit,
|
||||||
|
onConnected: suspend () -> Unit,
|
||||||
|
onDisconnected: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
client.connect(onMessage, onConnected, onDisconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(json: String) {
|
||||||
|
client.send(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
|||||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||||
import pw.binom.viewmate.core.protocol.SetMode
|
import pw.binom.viewmate.core.protocol.SetMode
|
||||||
import pw.binom.viewmate.core.protocol.SetScale
|
import pw.binom.viewmate.core.protocol.SetScale
|
||||||
|
import pw.binom.viewmate.core.protocol.SetVertical
|
||||||
import pw.binom.viewmate.core.protocol.ShowText
|
import pw.binom.viewmate.core.protocol.ShowText
|
||||||
import pw.binom.viewmate.core.protocol.Shutdown
|
import pw.binom.viewmate.core.protocol.Shutdown
|
||||||
|
|
||||||
@@ -91,8 +92,15 @@ class PhoneActions(
|
|||||||
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
|
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
|
||||||
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
||||||
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
|
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
|
||||||
|
* [startPositionMs] — стартовая позиция (seek для запуска с середины).
|
||||||
*/
|
*/
|
||||||
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = "", phoneAudioReady: Boolean = true): String {
|
suspend fun watch(
|
||||||
|
itemId: String,
|
||||||
|
audioIndex: Int = 0,
|
||||||
|
localVideoPath: String = "",
|
||||||
|
phoneAudioReady: Boolean = true,
|
||||||
|
startPositionMs: Long = 0,
|
||||||
|
): String {
|
||||||
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||||
val status = mirror.mirrorByItem(itemId)
|
val status = mirror.mirrorByItem(itemId)
|
||||||
val files = status?.files
|
val files = status?.files
|
||||||
@@ -107,7 +115,7 @@ class PhoneActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val title = item.Name ?: itemId
|
val title = item.Name ?: itemId
|
||||||
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
|
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex, startPositionMs = startPositionMs))
|
||||||
// очки не помнят scale между запусками — при старте просмотра шлём текущий
|
// очки не помнят scale между запусками — при старте просмотра шлём текущий
|
||||||
if (state.scale != 1f) sender.send(SetScale(state.scale))
|
if (state.scale != 1f) sender.send(SetScale(state.scale))
|
||||||
state.mode = GlassesMode.MOVIE
|
state.mode = GlassesMode.MOVIE
|
||||||
@@ -268,6 +276,13 @@ class PhoneActions(
|
|||||||
return "масштаб → $clamped"
|
return "масштаб → $clamped"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Вертикаль экрана очков: SetVertical (0..1; 1 — верх, 0 — низ). */
|
||||||
|
suspend fun applyVertical(vertical: Float): String {
|
||||||
|
val clamped = vertical.coerceIn(0f, 1f)
|
||||||
|
sender.send(SetVertical(clamped))
|
||||||
|
return "высота → $clamped"
|
||||||
|
}
|
||||||
|
|
||||||
/** Закрыть приложение на очках (телефон остаётся открытым). */
|
/** Закрыть приложение на очках (телефон остаётся открытым). */
|
||||||
suspend fun shutdownGlasses(): String {
|
suspend fun shutdownGlasses(): String {
|
||||||
sender.send(Shutdown())
|
sender.send(Shutdown())
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ class PhoneState {
|
|||||||
log("state", "масштаб → $value")
|
log("state", "масштаб → $value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _vertical = MutableStateFlow(1f)
|
||||||
|
val verticalFlow: StateFlow<Float> = _vertical.asStateFlow()
|
||||||
|
/** Вертикаль экрана очков (0..1): 1.0 — у верха, 0.0 — нижняя кромка у низа. */
|
||||||
|
var vertical: Float
|
||||||
|
get() = _vertical.value
|
||||||
|
set(value) {
|
||||||
|
_vertical.value = value.coerceIn(0f, 1f)
|
||||||
|
log("state", "высота → $value")
|
||||||
|
}
|
||||||
|
|
||||||
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
|
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
|
||||||
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
|
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
|
||||||
var currentItem: JellyfinItem?
|
var currentItem: JellyfinItem?
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlas
|
|||||||
@SerialName("set_scale")
|
@SerialName("set_scale")
|
||||||
data class SetScale(val scale: Float) : HostToGlasses
|
data class SetScale(val scale: Float) : HostToGlasses
|
||||||
|
|
||||||
|
/** Вертикальное положение экрана очков (0..1); 1.0 — у верха, 0.0 — нижняя кромка у низа. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("set_vertical")
|
||||||
|
data class SetVertical(val vertical: Float) : HostToGlasses
|
||||||
|
|
||||||
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
|
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("stt_phrase")
|
@SerialName("stt_phrase")
|
||||||
@@ -91,10 +96,32 @@ data class SttPhrase(val phrase: String, val full: String) : HostToGlasses
|
|||||||
@SerialName("stt_done")
|
@SerialName("stt_done")
|
||||||
data class SttDone(val full: String) : HostToGlasses
|
data class SttDone(val full: String) : HostToGlasses
|
||||||
|
|
||||||
/** Распознавание отменено (двойной клик или 30 с тишины) — текст не выводится. */
|
/**
|
||||||
|
* Распознавание отменено — текст не выводится.
|
||||||
|
* reason="user" — пустой клик без голоса: очки возвращаются в «слушаю»;
|
||||||
|
* reason="cancel" — явная отмена диктовки двойным тапом: очки закрывают микрофон,
|
||||||
|
* listening=false, thinking=false, остаются в режиме (показывают последний ответ);
|
||||||
|
* reason="timeout" — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("stt_cancel")
|
@SerialName("stt_cancel")
|
||||||
class SttCancel : HostToGlasses
|
class SttCancel(val reason: String = REASON_USER) : HostToGlasses {
|
||||||
|
companion object {
|
||||||
|
const val REASON_USER = "user"
|
||||||
|
const val REASON_CANCEL = "cancel"
|
||||||
|
const val REASON_TIMEOUT = "timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один элемент истории сессии (только user/assistant — tool/calls очкам не нужны). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("chat_history_item")
|
||||||
|
data class ChatHistoryItem(val role: String, val content: String)
|
||||||
|
|
||||||
|
/** История сессии для очков (показ всего диалога со скроллом свайпами). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("chat_history")
|
||||||
|
data class ChatHistoryMsg(val sessionId: String?, val messages: List<ChatHistoryItem>) : HostToGlasses
|
||||||
|
|
||||||
/** Полное выключение приложения на очках (по команде с телефона). */
|
/** Полное выключение приложения на очках (по команде с телефона). */
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -221,5 +221,48 @@ class HostToGlassesTest {
|
|||||||
assertEquals("""{"type":"stt_cancel"}""", text)
|
assertEquals("""{"type":"stt_cancel"}""", text)
|
||||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
assertIs<SttCancel>(decoded)
|
assertIs<SttCancel>(decoded)
|
||||||
|
assertEquals("user", decoded.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sttCancelTimeoutRoundTrip() {
|
||||||
|
val msg: HostToGlasses = SttCancel(reason = "timeout")
|
||||||
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
|
assertEquals("""{"type":"stt_cancel","reason":"timeout"}""", text)
|
||||||
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
assertIs<SttCancel>(decoded)
|
||||||
|
assertEquals("timeout", decoded.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sttCancelBackwardCompatibleDefault() {
|
||||||
|
// Старое сообщение без поля reason десериализуется как "user".
|
||||||
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), """{"type":"stt_cancel"}""")
|
||||||
|
assertIs<SttCancel>(decoded)
|
||||||
|
assertEquals("user", decoded.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chatHistoryRoundTrip() {
|
||||||
|
val msg: HostToGlasses = ChatHistoryMsg(
|
||||||
|
sessionId = "s-1",
|
||||||
|
messages = listOf(
|
||||||
|
ChatHistoryItem(role = "user", content = "привет"),
|
||||||
|
ChatHistoryItem(role = "assistant", content = "здравствуй"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"chat_history","sessionId":"s-1","messages":[""" +
|
||||||
|
"""{"role":"user","content":"привет"},""" +
|
||||||
|
"""{"role":"assistant","content":"здравствуй"}]}""",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
assertIs<ChatHistoryMsg>(decoded)
|
||||||
|
assertEquals(2, decoded.messages.size)
|
||||||
|
assertEquals("user", decoded.messages[0].role)
|
||||||
|
assertEquals("привет", decoded.messages[0].content)
|
||||||
|
assertEquals("assistant", decoded.messages[1].role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,20 @@ class GlassesWsClientTest {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PATH = "/ws/glasses"
|
private const val PATH = "/ws/glasses"
|
||||||
|
|
||||||
|
private val json = protocolJson
|
||||||
|
|
||||||
|
private fun hostToGlasses(text: String): HostToGlasses =
|
||||||
|
json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
|
||||||
|
private fun glassesToHost(text: String): GlassesToHost =
|
||||||
|
json.decodeFromString(GlassesToHost.serializer(), text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
|
/** Локальный WS-сервер: Hello → Welcome, всё входящее (JSON-строками) собирает в очередь. */
|
||||||
private class TestServer(private val port: Int = 0) {
|
private class TestServer(private val port: Int = 0) {
|
||||||
val received = ConcurrentLinkedQueue<GlassesToHost>()
|
/** Входящие JSON-строки (транспорт — независим от протокола). */
|
||||||
|
val receivedRaw = ConcurrentLinkedQueue<String>()
|
||||||
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
||||||
|
|
||||||
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
||||||
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
|
val text = frame.readText()
|
||||||
received.add(msg)
|
receivedRaw.add(text)
|
||||||
|
val msg = glassesToHost(text)
|
||||||
if (msg is Hello) {
|
if (msg is Hello) {
|
||||||
send(protocolJson.encodeToString(
|
send(json.encodeToString(
|
||||||
HostToGlasses.serializer(),
|
HostToGlasses.serializer(),
|
||||||
Welcome(mode = GlassesMode.MOVIE),
|
Welcome(mode = GlassesMode.MOVIE),
|
||||||
))
|
))
|
||||||
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
|
|||||||
get() = sessions.size
|
get() = sessions.size
|
||||||
|
|
||||||
suspend fun broadcast(msg: HostToGlasses) {
|
suspend fun broadcast(msg: HostToGlasses) {
|
||||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
for (s in sessions) {
|
for (s in sessions) {
|
||||||
try {
|
try {
|
||||||
s.send(text)
|
s.send(text)
|
||||||
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val messages = mutableListOf<HostToGlasses>()
|
val messages = ConcurrentLinkedQueue<String>()
|
||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = { messages.add(it) },
|
onMessage = { messages.add(it) },
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
withTimeout(10_000) { connected.await() }
|
withTimeout(10_000) { connected.await() }
|
||||||
client.send(Hello(appVersion = "0.1.0"))
|
client.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
||||||
|
|
||||||
awaitTrue { messages.any { it is Welcome } }
|
awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
|
||||||
val welcome = messages.filterIsInstance<Welcome>().first()
|
val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
|
||||||
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
|
|||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = {},
|
onMessage = {},
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
withTimeout(10_000) { connected.await() }
|
withTimeout(10_000) { connected.await() }
|
||||||
client.send(Gesture(gesture = "DoubleClick"))
|
client.send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||||
|
|
||||||
awaitTrue { server.received.any { it is Gesture } }
|
awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
|
||||||
val gesture = server.received.filterIsInstance<Gesture>().first()
|
val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
|
||||||
|
glassesToHost(it) as Gesture
|
||||||
|
}
|
||||||
assertEquals("DoubleClick", gesture.gesture)
|
assertEquals("DoubleClick", gesture.gesture)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
|
|||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val messages = mutableListOf<HostToGlasses>()
|
val messages = ConcurrentLinkedQueue<String>()
|
||||||
val connected = CompletableDeferred<Unit>()
|
val connected = CompletableDeferred<Unit>()
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = { messages.add(it) },
|
onMessage = { messages.add(it) },
|
||||||
onConnected = { connected.complete(Unit) },
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
|
|||||||
|
|
||||||
server.broadcast(SetMode(GlassesMode.CHAT))
|
server.broadcast(SetMode(GlassesMode.CHAT))
|
||||||
|
|
||||||
awaitTrue { messages.any { it is SetMode } }
|
awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
|
||||||
val setMode = messages.filterIsInstance<SetMode>().first()
|
val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
|
||||||
assertEquals(GlassesMode.CHAT, setMode.mode)
|
assertEquals(GlassesMode.CHAT, setMode.mode)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
@@ -199,6 +214,50 @@ class GlassesWsClientTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка самого [GlassesTransport]: обмен — сырыми JSON-строками —
|
||||||
|
* и на стороне отправки (encode вызывающий), и на стороне приёма.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun transportApiExchangesRawJsonStrings() {
|
||||||
|
val server = TestServer()
|
||||||
|
try {
|
||||||
|
runBlocking {
|
||||||
|
val transport = WifiGlassesTransport(server.url())
|
||||||
|
val rawMessages = ConcurrentLinkedQueue<String>()
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
val job = launch {
|
||||||
|
transport.connect(
|
||||||
|
onMessage = { rawMessages.add(it) },
|
||||||
|
onConnected = { connected.complete(Unit) },
|
||||||
|
onDisconnected = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withTimeout(10_000) { connected.await() }
|
||||||
|
assertEquals("wifi", transport.name)
|
||||||
|
transport.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.3.0")))
|
||||||
|
|
||||||
|
awaitTrue { server.receivedRaw.any { it.contains("\"hello\"") } }
|
||||||
|
val helloJson = server.receivedRaw.first { it.contains("\"hello\"") }
|
||||||
|
val hello = glassesToHost(helloJson) as Hello
|
||||||
|
assertEquals("0.3.0", hello.appVersion)
|
||||||
|
|
||||||
|
// исходящее событие — тоже сырая строка; декод — за вызывающим
|
||||||
|
// (первый Welcome(MOVIE) — автоответ сервера на Hello; ждём наш CHAT)
|
||||||
|
server.broadcast(Welcome(mode = GlassesMode.CHAT))
|
||||||
|
awaitTrue { rawMessages.any { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT } }
|
||||||
|
val chatted = rawMessages.first { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT }
|
||||||
|
assertEquals(GlassesMode.CHAT, (hostToGlasses(chatted) as Welcome).mode)
|
||||||
|
|
||||||
|
transport.close()
|
||||||
|
job.cancelAndJoin()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun reconnectsAfterServerDrop() {
|
fun reconnectsAfterServerDrop() {
|
||||||
val server = TestServer()
|
val server = TestServer()
|
||||||
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
|
|||||||
val client = GlassesWsClient(server.url())
|
val client = GlassesWsClient(server.url())
|
||||||
val job = launch {
|
val job = launch {
|
||||||
client.connect(
|
client.connect(
|
||||||
onHostMessage = {},
|
onMessage = {},
|
||||||
onConnected = { connects++ },
|
onConnected = { connects++ },
|
||||||
|
onDisconnected = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user