Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a500150d | |||
| f68ea10342 | |||
| 5c86ed00b4 | |||
| 0b3f9ff975 | |||
| 40723fa584 | |||
| 728116662e | |||
| 0cfd92a68f | |||
| f656f43e10 | |||
| 1b28760c6e | |||
| b2e2d2697d | |||
| 92b483a5e3 | |||
| edf7cf63f6 | |||
| d5e60a2565 | |||
| 4587e22b50 | |||
| e18b82642f | |||
| b11879520e | |||
| 3af74851db | |||
| 1aa389a3a0 | |||
| 6cd4a8de43 | |||
| 62d2f06797 | |||
| cf8c5ef604 | |||
| 236ff18543 | |||
| 412e093174 | |||
| 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,84 @@
|
||||
# TASK: мягкая синхронизация аудио через скорость (rate-based sync)
|
||||
|
||||
## Контекст / проблема
|
||||
|
||||
В `view-mate` звук играет на **телефоне** (ExoPlayer media3), видео — на **очках** (очки — мастер, шлют позицию каждые 5с через `PlaybackPosition`). Телефон подстраивает аудио под позицию очков.
|
||||
|
||||
Сейчас коррекция в `AudioSyncPlayer.sync()` грубая:
|
||||
- раз в 5с (на каждый PlaybackPosition от очков) вызывается `sync(positionMs, isPlaying)`;
|
||||
- если `abs(audioPos - videoPos) > 1000` → **`seekTo(videoPos)`** — резкий рывок;
|
||||
- после seek ещё «догонка» через 1с (второй `seekTo`).
|
||||
|
||||
Проблема: звук на телефоне стабильно уезжает на ~1с вперёд от видео очков (статика `diff=1000` в паузе; при живом просмотре набирает >1000). Грубый `seekTo` при каждом пересечении порога рождает **повтор фразы** («нас заметили … нас заметили») и кодечки — слышимый дефект.
|
||||
|
||||
## Задача
|
||||
|
||||
Заменить резкий `seekTo`-корректор на **мягкую коррекцию через темп воспроизведения (PlaybackParameters.speed)**. Очки остаются мастером, телефон — ведомым: аудио подстраивается под позицию очков плавно, без рывков и повторов.
|
||||
|
||||
## Конкретные требования
|
||||
|
||||
### 1. Чистая функция решателя (заменить `decideSync`)
|
||||
|
||||
Переписать `decideSync` (сейчас возвращает `SyncDecision(seekToMs, playing)`) так, чтобы он возвращал **скорость** и лишь экстремально — seek:
|
||||
|
||||
```kotlin
|
||||
internal data class SyncDecision(
|
||||
val speed: Float = 1f, // желаемая скорость аудио
|
||||
val seekToMs: Long? = null, // только при большом рассинхроне (> HARD_SEEK_THRESHOLD)
|
||||
val playing: Boolean? = null,
|
||||
)
|
||||
```
|
||||
|
||||
Правила (пороги — константами, из названий ясны):
|
||||
- `abs(diff) <= SOFT_DRIFT_MS` (например 150мс) → связь «в норме»: `speed = 1f`.
|
||||
- `audioPos < videoPos` (звук отстаёт) → подгонять **вперёд**: `speed = 1f + DRIFT_RATE` (например `1.02`).
|
||||
- `audioPos > videoPos` (звук впереди) → подгонять **назад**: `speed = 1f - DRIFT_RATE` (например `0.98`).
|
||||
- `abs(diff) > HARD_SEEK_THRESHOLD` (например 2000ms) → **`seekToMs = videoPos`** (рассинхрон большой, скорость не нагонит вовремя) — но это редкость.
|
||||
- `playing` — как раньше (догон play/pause по состоянию мастера).
|
||||
|
||||
Учесть гистерезис: не менять направление скорости слишком часто. Например, помнить последнее направление и не переключать на «назад/вперёд» чаще чем раз в N (или не переключать, пока diff не станет > небольшой зоны около 0). Мин. простой вариант: **TRIGGER_BAND_MS** — скорость меняется/выравнивается только когда diff за пределами зоны `[-TRIGGER_BAND, +TRIGGER_BAND]` вокруг 0 (например 150ms); внутри зоны — `1f`. Это убирает дребезг.
|
||||
|
||||
### 2. Применение в `AudioSyncPlayer.sync()`
|
||||
|
||||
- Вызов `decideSync` с новым набором параметров.
|
||||
- Если `decision.seekToMs != null` — текущий `seekTo` (редкий случай, большой рассинхрон) + восстановить `speed=1f` после seek (seek сбрасывает позицию, дальше мягко).
|
||||
- Иначе (обычный путь): `player.setPlaybackParameters(PlaybackParameters(speed))` — **без `seekTo`**.
|
||||
- Убрать «догонку через 1с» (второй резкий seek) — при мягкой коррекции она не нужна и вредна.
|
||||
- `lastVideoPositionMs`, `lastVideoPlaying` — сохранить семантику из текущего.
|
||||
|
||||
Требование: **все операции ExoPlayer по-прежнему на main-потоке** (уже есть `main.post`). `setPlaybackParameters` вызывать внутри `main.post`.
|
||||
|
||||
### 3. Логирование
|
||||
|
||||
- В `sync` логировать теперь скорость: `audio sync: video=N audio=M diff=K speed=S` (S — применяемая скорость или 1f). Полезно для QA.
|
||||
- При seek (редкий) — как сейчас `audio sync: seek → N`.
|
||||
|
||||
### 4. Тесты (обязательно)
|
||||
|
||||
Переписать/добавить unit-тесты на новую `decideSync` (и/или rename в `decideSpeed`/`decideAdjustment`):
|
||||
- `audioPos` близко к `videoPos` (diff < порога мягкой зоны) → `speed == 1f`, `seekToMs == null`;
|
||||
- `audioPos` отстаёт (audio < video, диф в зоне до HARD) → `speed > 1f`, без seek;
|
||||
- `audioPos` впереди (audio > video, в зоне до HARD) → `speed < 1f`, без seek;
|
||||
- `abs(diff) > HARD_SEEK_THRESHOLD` → `seekToMs == videoPos`, speed по-прежнему 1f (после seek мягко подогнать);
|
||||
- `playing` (play/pause) отдельно — без влияния на speed;
|
||||
- boundary: ровно на границах порога (equals) — чёткое поведение.
|
||||
|
||||
Проверить, что старые тесты `AudioSyncPlayerTest` переписаны под новый контракт (скорость вместо seek в обычных случаях).
|
||||
|
||||
### 5. Не трогать фильмы/прошлое поведение
|
||||
|
||||
- На очках ничего менять (мастер позиции остаётся).
|
||||
- `play(url, startPositionMs)` — оставить: начальная позиция аудио при старте — `startPositionMs`, потом мягкая подгонка.
|
||||
- Суть: только `_phone_`/`AudioSyncPlayer` меняется.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt` (основной).
|
||||
- `app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt` (тесты).
|
||||
|
||||
## Требования к исполнению / команды
|
||||
|
||||
- Сборка и прогон тестов только через **локальный gradle** в репо (не трогать чужой infra).
|
||||
- Прогнать: `./gradlew :app-phone:testDebugUnitTest` (или `:app-phone:test` в исходном тесте — уточнить task по проекту) — тесты модуля phone проходят.
|
||||
- Код только на **Kotlin**, в стиле проекта (см. соседние файлы). Без лишних зависимостей.
|
||||
- НЕ коммитить сами — собрать и прогнать тесты, показать диф. (Коммит — после подтверждения здоров: образец ты решаешь/проверяешь сам.)
|
||||
@@ -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, агрессивная фоновая
|
||||
политика) замораживает/убивает процесс:
|
||||
|
||||
1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
|
||||
автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
|
||||
2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
|
||||
|
||||
## Решение
|
||||
|
||||
Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK +
|
||||
WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране
|
||||
телефона. Очки тогда не теряют связь, видео не паузится, звук играет.
|
||||
|
||||
### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`)
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
```
|
||||
Внутри `<application>` — сервис:
|
||||
```xml
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
```
|
||||
|
||||
### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
|
||||
|
||||
```kotlin
|
||||
fun ConnectionScreen() {
|
||||
val connection = GlassesApp.instance.hostConnection
|
||||
val connected by connection.connected.collectAsState()
|
||||
val status by connection.status.collectAsState()
|
||||
val messages by connection.messages.collectAsState()
|
||||
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
|
||||
|
||||
Binocular(scale = false) {
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
// масштаб через layout — как в GlassesMovieScreen
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = offsetX.px)
|
||||
.background(Color.Black),
|
||||
) {
|
||||
... существующий контент ...
|
||||
class PlaybackService : Service() {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "viewmate_playback"
|
||||
const val NOTIFICATION_ID = 1
|
||||
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(
|
||||
context, Intent(context, PlaybackService::class.java)
|
||||
)
|
||||
}
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, PlaybackService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// канал уведомлений (Android 8+)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "ViewMate playback",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) }
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle("ViewMate")
|
||||
.setContentText("Звук на телефоне при просмотре на очках")
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
// держим CPU и WiFi живыми при выключенном экране
|
||||
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
|
||||
?.apply { acquire() }
|
||||
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
|
||||
?.apply { acquire() }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
START_NOT_STICKY
|
||||
|
||||
override fun onDestroy() {
|
||||
wakeLock?.takeIf { it.isHeld }?.release()
|
||||
wifiLock?.takeIf { it.isHeld }?.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
```
|
||||
Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
|
||||
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
|
||||
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
|
||||
|
||||
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
|
||||
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
|
||||
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
|
||||
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
|
||||
|
||||
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
|
||||
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
|
||||
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
|
||||
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
|
||||
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
|
||||
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
|
||||
там есть `Side.LEFT/RIGHT` через CompositionLocal).
|
||||
- **Старт — при каждом старте аудио** (3 места):
|
||||
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
|
||||
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
|
||||
(строка ~127) и второй play (строка ~215)
|
||||
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
|
||||
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
|
||||
приложение живо) — НО только если пользователь не против постоянного
|
||||
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
|
||||
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
|
||||
уведомление — постоянный индикатор «ViewMate работает». Это проще и
|
||||
покрывает и watch, и autostartAudio, и STT-сессии.
|
||||
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
|
||||
|
||||
## Баг 2: скролл каталога теряется при навигации
|
||||
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
|
||||
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
|
||||
приложения-компаньона — так работает, например, в паре с очками).
|
||||
|
||||
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
|
||||
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
|
||||
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
|
||||
папку, потом ещё — и т.д.»
|
||||
### 4. Проверка
|
||||
|
||||
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
|
||||
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
|
||||
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
|
||||
|
||||
**Фикс:** добавить в `CatalogState`:
|
||||
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
|
||||
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
|
||||
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
|
||||
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
|
||||
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
|
||||
|
||||
В `CatalogScreen` composable:
|
||||
```kotlin
|
||||
val gridState = rememberLazyGridState()
|
||||
// восстанавливаем позицию при смене пути/загрузке
|
||||
LaunchedEffect(state.path, state.items) {
|
||||
val (idx, off) = state.savedScrollForCurrentPath()
|
||||
gridState.scrollToItem(idx, off)
|
||||
}
|
||||
// сохраняем позицию при скролле
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow {
|
||||
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
|
||||
}.collect { (idx, off) -> state.setScroll(idx, off) }
|
||||
}
|
||||
```
|
||||
и `LazyVerticalGrid(state = gridState, ...)`.
|
||||
|
||||
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
|
||||
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
|
||||
каждый уровень сохраняет свою позицию.
|
||||
|
||||
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
|
||||
`androidx.compose.runtime.snapshotFlow`.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
|
||||
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
|
||||
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
|
||||
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
|
||||
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
|
||||
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
|
||||
3. На железе (через 142): установить APK, запустить, начать просмотр,
|
||||
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
|
||||
проверить: звук играет, видео на очках играет (позиция растёт в логах),
|
||||
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.io.File
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -5,6 +7,20 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
|
||||
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
|
||||
val binomCertsDir: File = File(
|
||||
(project.findProperty("binom.keystore.dir") as? String)
|
||||
?: System.getenv("BINOM_KEYSTORE_DIR")
|
||||
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
|
||||
)
|
||||
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
|
||||
val binomCreds: Map<String, String> =
|
||||
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
|
||||
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
|
||||
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
|
||||
else emptyMap() })
|
||||
|
||||
android {
|
||||
namespace = "pw.binom.viewmate.glasses"
|
||||
compileSdk = 35
|
||||
@@ -17,9 +33,30 @@ android {
|
||||
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
|
||||
// Без кестора binomCreds пусто → release остаётся неподписанным.
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
|
||||
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
|
||||
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
|
||||
signingConfigs {
|
||||
create("binom") {
|
||||
storeFile = binomStoreFile
|
||||
storePassword = binomStorePass
|
||||
keyAlias = binomAlias
|
||||
keyPassword = binomKeyPass
|
||||
enableV1Signing = false
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
signingConfig = signingConfigs.getByName("binom")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +98,16 @@ dependencies {
|
||||
implementation(project(":lib-core"))
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// Локальный Qwen3-ASR на очках (движок + распознавание переезжают на очки из app-phone).
|
||||
// asr-qwen3-android тянет sherpa-onnx (nativ) транзитивно; модель — в filesDir/models
|
||||
// (SttModelProvider), как на телефоне.
|
||||
implementation("pw.binom.asr:asr-qwen3-android:4")
|
||||
|
||||
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
|
||||
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
|
||||
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
|
||||
// implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
|
||||
|
||||
// WS-клиент очков (OkHttp — Android-движок)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<!-- Bluetooth SPP-транспорт (включается флагом GlassesConfig.USE_BLUETOOTH):
|
||||
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
|
||||
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
|
||||
<application
|
||||
android:name=".GlassesApp"
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.core.log
|
||||
import pw.binom.viewmate.core.net.BtTransportConstants
|
||||
import pw.binom.viewmate.core.net.GlassesTransport
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Bluetooth-транспорт, сторона очков («клиент») — TASK-transport.md п.3:
|
||||
* RFCOMM/SPP-клиент ([BtTransportConstants.SPP_UUID]): [connect] — бесконечный
|
||||
* цикл подключения с реконнектом через [BtTransportConstants.RETRY_DELAY_MS]
|
||||
* (цикл живёт внутри транспорта), строки-сообщения (JSON + перевод строки —
|
||||
* разделитель) читаются на Dispatchers.IO, запись — под синхронизацией на
|
||||
* сокете. Адрес телефона берётся из [GlassesConfig.btRemoteMac].
|
||||
* Включается флагом [GlassesConfig.USE_BLUETOOTH] (дефолт — WiFi).
|
||||
*/
|
||||
class BtGlassesTransport(
|
||||
private val context: Context,
|
||||
) : GlassesTransport {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Runtime-пермишены, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12) + RECORD_AUDIO:
|
||||
* микрофон локального STT — без него AudioRecord не стартует и сессия
|
||||
* молча падает в файловую эмуляцию (пустой буфер).
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||
*/
|
||||
fun runtimePermissions(): Array<String> =
|
||||
(if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}) + Manifest.permission.RECORD_AUDIO
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||
runtimePermissions().all {
|
||||
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
override val name = "bluetooth"
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
@Volatile
|
||||
private var socket: BluetoothSocket? = null
|
||||
|
||||
private val outLock = Any()
|
||||
private var out: OutputStream? = null
|
||||
|
||||
/** Бесконечный цикл подключения+реконнекта: в корутине вызывающего, до [close]. */
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun connect(
|
||||
onMessage: suspend (String) -> Unit,
|
||||
onConnected: suspend () -> Unit,
|
||||
onDisconnected: suspend () -> Unit,
|
||||
) {
|
||||
while (!closed) {
|
||||
var connected = false
|
||||
if (socket == null) {
|
||||
if (!ensureSocket()) {
|
||||
log("bt", "SPP не создан (радио/пермишен/адрес?) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
log("bt", "SPP-клиент готов: ${GlassesConfig.btRemoteMac()}")
|
||||
}
|
||||
try {
|
||||
val sock = socket!!
|
||||
withContext(Dispatchers.IO) { sock.connect() }
|
||||
connected = true
|
||||
out = sock.outputStream
|
||||
log("bt", "SPP-канал открыт: ${GlassesConfig.btRemoteMac()}")
|
||||
onConnected()
|
||||
// readLines: EOF (партнёр закрыл) или исключение (обрыв/close)
|
||||
readLines(sock, onMessage)
|
||||
if (!closed) {
|
||||
log("bt", "SPP-соединение оборвано")
|
||||
onDisconnected()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("bt", "SPP-ошибка (${e::class.simpleName}: ${e.message}) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||
if (!closed && connected) runCatching { onDisconnected() }
|
||||
}
|
||||
runCatching { out?.close() }
|
||||
out = null
|
||||
runCatching { socket?.close() }
|
||||
socket = null
|
||||
if (closed) break
|
||||
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** RFCOMM/SPP-сокет к [GlassesConfig.btRemoteMac]; без радио/пермишена/адреса — false. */
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun ensureSocket(): Boolean {
|
||||
val mac = GlassesConfig.btRemoteMac()
|
||||
if (mac.isNullOrBlank()) {
|
||||
log("bt", "адрес телефона не задан (GlassesConfig.BT_REMOTE_MAC) — SPP недоступен")
|
||||
return false
|
||||
}
|
||||
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
|
||||
log("bt", "BT-радио не найдено — SPP недоступен")
|
||||
return false
|
||||
}
|
||||
if (!adapter.isEnabled) {
|
||||
log("bt", "Bluetooth выключен — включите в настройках Android")
|
||||
return false
|
||||
}
|
||||
if (!hasBtPermissions(context)) {
|
||||
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
|
||||
return false
|
||||
}
|
||||
val dev = try {
|
||||
adapter.getRemoteDevice(mac)
|
||||
} catch (e: Exception) {
|
||||
log("bt", "устройство не найдено: ${e.message}")
|
||||
null
|
||||
}
|
||||
if (dev == null) return false
|
||||
val s = try {
|
||||
dev.createRfcommSocketToServiceRecord(UUID.fromString(BtTransportConstants.SPP_UUID))
|
||||
} catch (e: Exception) {
|
||||
log("bt", "не удалось создать SPP-сокет: ${e.message}")
|
||||
null
|
||||
}
|
||||
if (s == null) return false
|
||||
socket = s
|
||||
return true
|
||||
}
|
||||
|
||||
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
|
||||
private suspend fun readLines(s: BluetoothSocket, onMessage: suspend (String) -> Unit) {
|
||||
val reader = BufferedReader(InputStreamReader(s.inputStream))
|
||||
while (!closed) {
|
||||
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
|
||||
if (line.isNotBlank()) onMessage(line)
|
||||
}
|
||||
runCatching { reader.close() }
|
||||
}
|
||||
|
||||
/** JSON-строка в сокет: UTF-8 + разделитель, под синхронизацией. Без соединения — no-op. */
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun send(json: String) {
|
||||
val o = out ?: return
|
||||
runCatching {
|
||||
synchronized(outLock) {
|
||||
o.write(json.toByteArray(Charsets.UTF_8))
|
||||
o.write(BtTransportConstants.NEWLINE.code)
|
||||
o.flush()
|
||||
}
|
||||
}.onFailure { log("bt", "запись не удалась: ${it.message}") }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,38 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.rayneo.arsdk.android.MercurySDK
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
import pw.binom.viewmate.core.log.FileLogSpool
|
||||
import pw.binom.viewmate.core.log.SystemClock
|
||||
import pw.binom.viewmate.glasses.stt.GlassesAsr
|
||||
import pw.binom.viewmate.glasses.stt.GlassesSttSession
|
||||
import pw.binom.viewmate.glasses.stt.SttModelDownloader
|
||||
|
||||
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||
data class ChatEntry(val role: String, val text: String)
|
||||
|
||||
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
||||
object GlassesConfig {
|
||||
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
||||
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
||||
|
||||
/**
|
||||
* Выбор транспорта (TASK-transport.md п.4): `false` (дефолт) — WiFi
|
||||
* ([WifiGlassesTransport], поведение без изменений); `true` — Bluetooth SPP
|
||||
* ([BtGlassesTransport], manual-включение).
|
||||
*/
|
||||
const val USE_BLUETOOTH = true
|
||||
|
||||
/** MAC-адрес телефона-партнёра для SPP (нужен только при USE_BLUETOOTH=true). */
|
||||
val BT_REMOTE_MAC: String? = "08:84:FB:D5:BD:3E"
|
||||
|
||||
/** Адрес SPP-партнёра (null → SPP недоступен, транспорт ляжет с ретраями). */
|
||||
fun btRemoteMac(): String? = BT_REMOTE_MAC
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +54,26 @@ class GlassesApp : Application() {
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
|
||||
/** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */
|
||||
lateinit var sttModel: SttModelDownloader
|
||||
private set
|
||||
|
||||
/** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */
|
||||
lateinit var asr: GlassesAsr
|
||||
private set
|
||||
|
||||
/** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */
|
||||
lateinit var sttSession: GlassesSttSession
|
||||
private set
|
||||
|
||||
|
||||
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
||||
lateinit var logCollector: BatchingLogCollector
|
||||
private set
|
||||
|
||||
/** Скоуп фоновых корутин (таймер лог-коллектора). */
|
||||
private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -36,7 +81,14 @@ class GlassesApp : Application() {
|
||||
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
|
||||
val scale = MutableStateFlow(1f)
|
||||
|
||||
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
|
||||
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
|
||||
val vertical = MutableStateFlow(1f)
|
||||
|
||||
/**
|
||||
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click,
|
||||
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
|
||||
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
|
||||
*/
|
||||
val listening = MutableStateFlow(false)
|
||||
|
||||
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
|
||||
@@ -45,6 +97,34 @@ class GlassesApp : Application() {
|
||||
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||
val assistantText = MutableStateFlow("")
|
||||
|
||||
/**
|
||||
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
|
||||
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
|
||||
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
|
||||
*/
|
||||
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
|
||||
|
||||
/**
|
||||
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
|
||||
*/
|
||||
val thinking = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
|
||||
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
|
||||
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
|
||||
*/
|
||||
val answerScrollDelta = MutableStateFlow(0f)
|
||||
|
||||
/**
|
||||
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
||||
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
|
||||
* (видео на очках не играет / данных ещё нет).
|
||||
*/
|
||||
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
|
||||
|
||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||
var mainActivity: MainActivity? = null
|
||||
|
||||
@@ -55,10 +135,18 @@ class GlassesApp : Application() {
|
||||
prefs.edit().putFloat("scale", v).apply()
|
||||
}
|
||||
|
||||
/** Применить и сохранить вертикаль (0..1). */
|
||||
fun applyVertical(value: Float) {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
vertical.value = v
|
||||
prefs.edit().putFloat("vertical", v).apply()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
|
||||
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
|
||||
MercurySDK.init(this)
|
||||
movieController = MovieController(applicationContext)
|
||||
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
|
||||
@@ -71,6 +159,35 @@ class GlassesApp : Application() {
|
||||
)
|
||||
hostConnection.start()
|
||||
log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}")
|
||||
|
||||
// Лог-коллектор: пачки → телефон; обрыв связи — спул в filesDir, ретраи таймером.
|
||||
logCollector = BatchingLogCollector(
|
||||
device = "glasses",
|
||||
sink = GlassesLinkLogSink(hostConnection),
|
||||
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 32),
|
||||
clock = SystemClock(),
|
||||
flushIntervalMs = 60_000,
|
||||
maxBatchEntries = 500,
|
||||
)
|
||||
logCollector.start(bgScope)
|
||||
attachLogCollector(logCollector)
|
||||
pw.binom.viewmate.core.logCollector = logCollector
|
||||
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
|
||||
|
||||
// Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range).
|
||||
sttModel = SttModelDownloader(File(filesDir, "models"), bgScope)
|
||||
sttModel.ensure()
|
||||
|
||||
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
|
||||
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity.
|
||||
asr = GlassesAsr(File(filesDir, "models"))
|
||||
sttSession = GlassesSttSession(
|
||||
filesDir = filesDir,
|
||||
scope = bgScope,
|
||||
recognizer = { asr.recognize(it) },
|
||||
modelReady = { sttModel.status.value.ready },
|
||||
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -99,7 +99,8 @@ internal class GlassesStateStore(private val file: File) {
|
||||
|
||||
/**
|
||||
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
|
||||
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part
|
||||
* Каталог: filesDir/viewmate/{itemId}/video.mkv — на очки уходит только видео,
|
||||
* звук (audio-N.ogg) не скачивается, он лежит на телефоне. Скачивание пишется в .part
|
||||
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
|
||||
*/
|
||||
class GlassesDownloadManager(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import pw.binom.viewmate.core.log.LogBatch
|
||||
import pw.binom.viewmate.core.log.LogSink
|
||||
import pw.binom.viewmate.core.protocol.LogBatchMsg
|
||||
|
||||
/**
|
||||
* LogSink коллектора очков: пачка уходит на телефон по связи очков↔телефон
|
||||
* ([LogBatchMsg]). Связи нет → false: пачка прихраняется в споуле очков
|
||||
* (filesDir) и ретраится после восстановления связи.
|
||||
*/
|
||||
class GlassesLinkLogSink(
|
||||
private val connection: HostConnection,
|
||||
) : LogSink {
|
||||
override suspend fun send(batch: LogBatch): Boolean {
|
||||
if (!connection.connected.value) return false
|
||||
connection.sendToHost(LogBatchMsg(entries = batch.entries))
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,19 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.net.GlassesWsClient
|
||||
import pw.binom.viewmate.core.net.GlassesTransport
|
||||
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.AudioSyncInfo
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
@@ -31,19 +37,22 @@ import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.SetScale
|
||||
import pw.binom.viewmate.core.protocol.SetVertical
|
||||
import pw.binom.viewmate.core.protocol.Shutdown
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
|
||||
/**
|
||||
* Обвязка над [GlassesWsClient] для UI очков:
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
||||
* Hello при подключении, периодический статус/позиция,
|
||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
||||
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
|
||||
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
|
||||
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
|
||||
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
|
||||
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
|
||||
*/
|
||||
class HostConnection(
|
||||
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
||||
@@ -56,11 +65,14 @@ class HostConnection(
|
||||
private val discovery = MdnsClient(context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
|
||||
@Volatile
|
||||
private var client: GlassesWsClient? = null
|
||||
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||||
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
|
||||
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||||
@Volatile
|
||||
private var transport: GlassesTransport? = null
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||||
@Volatile
|
||||
private var lastUrl: String? = null
|
||||
|
||||
@@ -81,51 +93,97 @@ class HostConnection(
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(5_000)
|
||||
client?.send(buildStatus())
|
||||
sendToHost(buildStatus())
|
||||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||||
client?.send(
|
||||
sendToHost(
|
||||
PlaybackPosition(
|
||||
positionMs = snapshot.positionMs,
|
||||
playing = snapshot.playing,
|
||||
itemId = snapshot.itemId,
|
||||
audioIndex = snapshot.audioIndex,
|
||||
sentTs = System.currentTimeMillis(),
|
||||
durationMs = snapshot.durationMs,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
|
||||
private suspend fun connectLoop() {
|
||||
if (useBt) connectLoopBt() else connectLoopWifi()
|
||||
}
|
||||
|
||||
/**
|
||||
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
|
||||
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||||
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||||
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
|
||||
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||||
* повторный mDNS-цикл.
|
||||
*/
|
||||
private suspend fun connectLoop() {
|
||||
private suspend fun connectLoopWifi() {
|
||||
val url = lastUrl ?: discoverPhoneUrl()
|
||||
if (url.isEmpty()) {
|
||||
log("app", "телефон не найден — повтор поиска через 3с")
|
||||
delay(3_000)
|
||||
connectLoop()
|
||||
connectLoopWifi()
|
||||
return
|
||||
}
|
||||
lastUrl = url
|
||||
log("app", "подключаюсь к $url")
|
||||
log("app", "подключаюсь к $url (WiFi)")
|
||||
|
||||
val c = GlassesWsClient(url)
|
||||
client = c
|
||||
c.connect(
|
||||
onHostMessage = { msg -> handleHostMessage(msg) },
|
||||
val t = WifiGlassesTransport(url)
|
||||
transport = t
|
||||
t.connect(
|
||||
onMessage = { text -> handleHostMessageText(text) },
|
||||
onConnected = {
|
||||
_connected.value = true
|
||||
_status.value = "подключено: $url"
|
||||
_status.value = "подключено: ${t.name} $url"
|
||||
addMessage("соединение установлено")
|
||||
discovery.stop()
|
||||
c.send(Hello(appVersion = APP_VERSION))
|
||||
sendToHost(Hello(appVersion = APP_VERSION))
|
||||
if (reconnectResume.consume()) {
|
||||
withContext(Dispatchers.Main) { movieController.resume() }
|
||||
addMessage("связь восстановлена — продолжаю")
|
||||
}
|
||||
},
|
||||
onDisconnected = {
|
||||
_connected.value = false
|
||||
_status.value = "нет соединения (реконнект через 3с)"
|
||||
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
|
||||
reconnectResume.onConnectionLost(wasPlaying)
|
||||
if (wasPlaying) addMessage("связь потеряна — пауза")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено
|
||||
* вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect].
|
||||
*/
|
||||
private suspend fun connectLoopBt() {
|
||||
val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "")
|
||||
if (mac.isEmpty()) {
|
||||
log("app", "BT: MAC-адрес телефона не задан — SPP недоступен")
|
||||
delay(3_000)
|
||||
connectLoopBt()
|
||||
return
|
||||
}
|
||||
lastUrl = mac
|
||||
log("app", "подключаюсь к $mac (Bluetooth SPP)")
|
||||
|
||||
val t = BtGlassesTransport(appContext)
|
||||
transport = t
|
||||
t.connect(
|
||||
onMessage = { text -> handleHostMessageText(text) },
|
||||
onConnected = {
|
||||
_connected.value = true
|
||||
_status.value = "подключено: bluetooth $mac"
|
||||
addMessage("соединение установлено")
|
||||
sendToHost(Hello(appVersion = APP_VERSION))
|
||||
if (reconnectResume.consume()) {
|
||||
withContext(Dispatchers.Main) { movieController.resume() }
|
||||
addMessage("связь восстановлена — продолжаю")
|
||||
@@ -145,7 +203,7 @@ class HostConnection(
|
||||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||||
*/
|
||||
private suspend fun discoverPhoneUrl(): String {
|
||||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||||
@@ -171,18 +229,29 @@ class HostConnection(
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */
|
||||
private suspend fun handleHostMessageText(text: String) {
|
||||
val msg = runCatching {
|
||||
protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
||||
}.onFailure {
|
||||
log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})")
|
||||
}.getOrNull() ?: return
|
||||
handleHostMessage(msg)
|
||||
}
|
||||
|
||||
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
||||
suspend fun sendToHost(msg: GlassesToHost) {
|
||||
client?.send(msg)
|
||||
val t = transport ?: return
|
||||
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||||
}
|
||||
|
||||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||||
fun sendPlaybackPositionNow() {
|
||||
val c = client ?: return
|
||||
if (transport == null) return
|
||||
scope.launch {
|
||||
val (positionMs, playing, itemId, audioIndex) =
|
||||
val (positionMs, playing, itemId, audioIndex, durationMs) =
|
||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis(), durationMs = durationMs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +313,11 @@ class HostConnection(
|
||||
log("app", "scale → ${msg.scale}")
|
||||
}
|
||||
|
||||
is SetVertical -> {
|
||||
GlassesApp.instance.applyVertical(msg.vertical)
|
||||
log("app", "vertical → ${msg.vertical}")
|
||||
}
|
||||
|
||||
is PlayVideo -> {
|
||||
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
||||
reconnectResume.reset()
|
||||
@@ -266,15 +340,34 @@ class HostConnection(
|
||||
downloadManager.handleDeleteFiles(msg.itemId)
|
||||
}
|
||||
|
||||
is AudioSyncInfo -> {
|
||||
// Индикатор |звук − видео| для статус-панели; active=false (видео не играет) — скрываем.
|
||||
val prev = GlassesApp.instance.audioSyncDiffMs.value
|
||||
GlassesApp.instance.audioSyncDiffMs.value = if (msg.active) msg.diffMs else null
|
||||
// Диагностика: скачок diff (по часам очков) — для корреляции с logcat BT-стека телефона за ту же секунду.
|
||||
if (msg.active && prev != null && msg.diffMs - prev >= 300) {
|
||||
log("audio", "diff скачок $prev→${msg.diffMs} мс")
|
||||
}
|
||||
}
|
||||
|
||||
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
|
||||
|
||||
is ShowText -> {
|
||||
addMessage("ShowText: ${msg.text}")
|
||||
GlassesApp.instance.thinking.value = false
|
||||
GlassesApp.instance.answerScrollDelta.value = 0f
|
||||
GlassesApp.instance.assistantText.value = msg.text
|
||||
}
|
||||
|
||||
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
||||
|
||||
is ChatHistoryMsg -> {
|
||||
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
|
||||
GlassesApp.instance.chatHistory.value = msg.messages
|
||||
.filter { it.role == "user" || it.role == "assistant" }
|
||||
.map { ChatEntry(it.role, it.content) }
|
||||
}
|
||||
|
||||
is SttPhrase -> {
|
||||
GlassesApp.instance.sttText.value = msg.full
|
||||
log("stt", "фраза: ${msg.phrase}")
|
||||
@@ -286,8 +379,31 @@ class HostConnection(
|
||||
}
|
||||
|
||||
is SttCancel -> {
|
||||
log("stt", "отменено")
|
||||
GlassesApp.instance.listening.value = false
|
||||
when (msg.reason) {
|
||||
SttCancel.REASON_CANCEL -> {
|
||||
// Явная отмена диктовки двойным тапом: очки САМИ уже вернулись
|
||||
// в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат,
|
||||
// оверлей остаётся, микрофон готов к следующей записи). Это всего
|
||||
// подтверждение, что телефон отменил STT-сессию (stt.reset()) —
|
||||
// состояния (listening/thinking/оверлей) НЕ трогаем.
|
||||
log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте")
|
||||
}
|
||||
SttCancel.REASON_TIMEOUT -> {
|
||||
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
|
||||
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
|
||||
log("stt", "тишина 30с — выход из режима общения")
|
||||
GlassesApp.instance.mainActivity?.onSttTimeout()
|
||||
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
|
||||
}
|
||||
else -> {
|
||||
// reason="user" — пустой клик без голоса: не выходим из режима,
|
||||
// возвращаемся в «слушаю» (микрофон снова открыт).
|
||||
log("stt", "отменено — возврат в «слушаю»")
|
||||
GlassesApp.instance.thinking.value = false
|
||||
GlassesApp.instance.mainActivity?.resumeSttListening()
|
||||
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Shutdown -> {
|
||||
@@ -306,7 +422,7 @@ class HostConnection(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
client?.close()
|
||||
transport?.close()
|
||||
discovery.stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
/** Единый лог очков: простой println (работает и в unit-тестах). */
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог очков: println (видно в юнит-тестах) + лог-коллектор — пачки
|
||||
* уходят на телефон ([GlassesLinkLogSink]); при обрыве связи пачки
|
||||
* прихраняются в споуле очков (filesDir) и ретраятся позже.
|
||||
*/
|
||||
@Volatile
|
||||
private var logCollector: BatchingLogCollector? = null
|
||||
|
||||
/** Подключить коллектор (GlassesApp.onCreate, после HostConnection). */
|
||||
fun attachLogCollector(collector: BatchingLogCollector) {
|
||||
logCollector = collector
|
||||
}
|
||||
|
||||
fun log(tag: String, message: String) {
|
||||
println("[view-mate] $tag $message")
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -18,7 +19,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -36,7 +36,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -49,12 +48,14 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.core.protocol.SttText
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
||||
import pw.binom.viewmate.glasses.gestures.actionName
|
||||
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
|
||||
import pw.binom.viewmate.glasses.gestures.assistantMode
|
||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
||||
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||
import pw.binom.viewmate.glasses.ui.Binocular
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
||||
@@ -62,15 +63,11 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
|
||||
import pw.binom.viewmate.glasses.ui.Side
|
||||
import pw.binom.viewmate.glasses.ui.px
|
||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||
import pw.binom.viewmate.glasses.ui.panelOffsetX
|
||||
|
||||
class MainActivity : BaseEventActivity() {
|
||||
private var resumeAfterScreenOn = false
|
||||
|
||||
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
|
||||
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
|
||||
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
|
||||
}
|
||||
|
||||
/**
|
||||
* DEBUG-имитация жестов (эмулятор без трекпада):
|
||||
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
|
||||
@@ -79,9 +76,9 @@ class MainActivity : BaseEventActivity() {
|
||||
private val debugReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.getStringExtra("type")) {
|
||||
"triple_click" -> handleTripleClick()
|
||||
"click" -> handleClick()
|
||||
"double_click" -> handleDoubleClick()
|
||||
"triple_click" -> onTempleAction(TempleAction.TripleClick())
|
||||
"click" -> onTempleAction(TempleAction.Click())
|
||||
"double_click" -> onTempleAction(TempleAction.DoubleClick())
|
||||
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
||||
}
|
||||
}
|
||||
@@ -116,6 +113,7 @@ class MainActivity : BaseEventActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
GlassesApp.instance.mainActivity = this
|
||||
requestBtPermissions()
|
||||
collectTempleActions()
|
||||
disableTrackpadSounds()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -135,6 +133,9 @@ class MainActivity : BaseEventActivity() {
|
||||
} else {
|
||||
ConnectionScreen()
|
||||
}
|
||||
// Оверлей режима общения: поверх любого экрана (затемняет кино),
|
||||
// виден в «слушаю»/«думаю»/«ответ виден».
|
||||
AssistantOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,78 +189,193 @@ class MainActivity : BaseEventActivity() {
|
||||
}, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
|
||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
||||
* завершить распознавание, DoubleClick — отмена.
|
||||
*/
|
||||
private fun requestBtPermissions() {
|
||||
if (!GlassesConfig.USE_BLUETOOTH) return
|
||||
val granted = BtGlassesTransport.runtimePermissions().all {
|
||||
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (granted) return
|
||||
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
|
||||
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
||||
}
|
||||
|
||||
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
|
||||
private fun collectTempleActions() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
templeActionViewModel.state.collect { action ->
|
||||
when (action) {
|
||||
is TempleAction.TripleClick -> handleTripleClick()
|
||||
is TempleAction.Click -> handleClick()
|
||||
is TempleAction.DoubleClick -> handleDoubleClick()
|
||||
is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
|
||||
val cmd = templeActionToCommand(action)
|
||||
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
|
||||
if (cmd != null) {
|
||||
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> log("gesture", "жест (резерв): ${actionName(action)}")
|
||||
}
|
||||
onTempleAction(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
|
||||
private fun handleTripleClick() {
|
||||
if (GlassesApp.instance.listening.value) {
|
||||
/**
|
||||
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
||||
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
|
||||
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
|
||||
*/
|
||||
private fun onTempleAction(action: TempleAction) {
|
||||
val app = GlassesApp.instance
|
||||
val state = AssistantState(
|
||||
listening = app.listening.value,
|
||||
thinking = app.thinking.value,
|
||||
sttText = app.sttText.value,
|
||||
assistantText = app.assistantText.value,
|
||||
)
|
||||
when (val decision = gestureDecision(action, state)) {
|
||||
GestureDecision.Ignore ->
|
||||
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
|
||||
GestureDecision.CallAssistant -> startAssistantMode()
|
||||
GestureDecision.TogglePlayback -> togglePlayback()
|
||||
GestureDecision.SendPhrase -> sendPhrase()
|
||||
GestureDecision.NewPhrase -> startNewPhrase()
|
||||
GestureDecision.CancelDictation -> cancelDictation()
|
||||
GestureDecision.ExitMode -> exitAssistantMode()
|
||||
is GestureDecision.Seek -> seekBy(decision)
|
||||
is GestureDecision.ScrollAnswer -> {
|
||||
val acc = app.answerScrollDelta.value + decision.delta
|
||||
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
|
||||
app.answerScrollDelta.value = acc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
|
||||
private fun startAssistantMode() {
|
||||
val app = GlassesApp.instance
|
||||
if (app.listening.value) {
|
||||
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
||||
return
|
||||
}
|
||||
log("stt", "ассистент: слушаю")
|
||||
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
|
||||
log("stt", "кино на паузе (TripleClick)")
|
||||
if (app.movieController.pauseIfPlaying()) {
|
||||
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||
}
|
||||
GlassesApp.instance.listening.value = true
|
||||
GlassesApp.instance.sttText.value = ""
|
||||
GlassesApp.instance.assistantText.value = ""
|
||||
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
app.listening.value = true
|
||||
app.thinking.value = false
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
|
||||
private fun handleClick() {
|
||||
if (GlassesApp.instance.listening.value) {
|
||||
log("gesture", "Click → завершить распознавание")
|
||||
sttMic.stop()
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||
}
|
||||
return
|
||||
}
|
||||
/** Click (кино): плей/пауза — как сегодня. */
|
||||
private fun togglePlayback() {
|
||||
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
||||
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
}
|
||||
}
|
||||
|
||||
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
|
||||
private fun handleDoubleClick() {
|
||||
if (GlassesApp.instance.listening.value) {
|
||||
log("gesture", "DoubleClick → отмена распознавания")
|
||||
sttMic.stop()
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
||||
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
|
||||
private fun sendPhrase() {
|
||||
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = true
|
||||
app.sttSession.send()
|
||||
}
|
||||
|
||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||
private fun startNewPhrase() {
|
||||
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
|
||||
val app = GlassesApp.instance
|
||||
app.assistantText.value = ""
|
||||
app.sttText.value = ""
|
||||
app.thinking.value = false
|
||||
app.answerScrollDelta.value = 0f
|
||||
app.listening.value = true
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
|
||||
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
|
||||
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
|
||||
*/
|
||||
fun resumeSttListening() {
|
||||
val app = GlassesApp.instance
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
app.sttSession.start()
|
||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||
}
|
||||
|
||||
/**
|
||||
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся
|
||||
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на
|
||||
* экране (даже если истории диалога ещё нет — отмена не закрывает чат).
|
||||
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]).
|
||||
*/
|
||||
private fun cancelDictation() {
|
||||
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
|
||||
val app = GlassesApp.instance
|
||||
app.sttSession.cancel()
|
||||
app.sttText.value = ""
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
|
||||
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
|
||||
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
|
||||
* слушать, пустой после лимита — выход из режима.
|
||||
*/
|
||||
fun onSttResult(text: String, auto: Boolean) {
|
||||
val app = GlassesApp.instance
|
||||
app.sttText.value = text
|
||||
when {
|
||||
text.isNotBlank() -> {
|
||||
app.listening.value = false
|
||||
app.thinking.value = true
|
||||
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон")
|
||||
lifecycleScope.launch {
|
||||
app.hostConnection.sendToHost(SttText(text))
|
||||
}
|
||||
}
|
||||
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
|
||||
else -> {
|
||||
log("stt", "клик без голоса — остаюсь в «слушаю»")
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
app.sttSession.start()
|
||||
}
|
||||
return
|
||||
}
|
||||
log("gesture", "жест (резерв): DoubleClick")
|
||||
}
|
||||
|
||||
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
||||
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
|
||||
|
||||
/**
|
||||
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
|
||||
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
|
||||
*/
|
||||
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||
|
||||
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
|
||||
private fun finishAssistantMode(reason: String) {
|
||||
log("gesture", reason)
|
||||
GlassesApp.instance.sttSession.stop()
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = false
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
}
|
||||
|
||||
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
|
||||
private fun seekBy(seek: GestureDecision.Seek) {
|
||||
log("gesture", "жест: свайп → ${seek.command}")
|
||||
GlassesApp.instance.movieController.applyMediaCommand(
|
||||
MediaCommandMsg(seek.command, seek.seekMs)
|
||||
) {
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,11 +403,18 @@ class MainActivity : BaseEventActivity() {
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
log("ui", "onPause")
|
||||
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
||||
val app = GlassesApp.instance
|
||||
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
|
||||
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
|
||||
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
|
||||
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
|
||||
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
|
||||
}
|
||||
val was = app.movieController.pauseIfPlaying()
|
||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||
logEvent("onPause: pause wasPlaying=$was")
|
||||
GlassesApp.instance.movieController.ensureWifiLock()
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
app.movieController.ensureWifiLock()
|
||||
app.hostConnection.sendPlaybackPositionNow()
|
||||
if (was) log("ui", "экран погас — пауза")
|
||||
}
|
||||
|
||||
@@ -373,67 +496,43 @@ fun ConnectionScreen() {
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
||||
.background(Color.Black),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(
|
||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||
// вертикаль (vertical) — только для видео; здесь контент всегда у верха
|
||||
)
|
||||
.background(Color.Black),
|
||||
) {
|
||||
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(if (connected) "подключено к телефону" else "нет соединения")
|
||||
Text(status, style = MaterialTheme.typography.bodyMedium)
|
||||
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(messages) { msg ->
|
||||
Text(msg, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusPanelCompose(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
scale = scale,
|
||||
)
|
||||
|
||||
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
|
||||
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
|
||||
// (assistantText) — ответ читается до новой сессии.
|
||||
val listening by GlassesApp.instance.listening.collectAsState()
|
||||
val sttText by GlassesApp.instance.sttText.collectAsState()
|
||||
val assistantText by GlassesApp.instance.assistantText.collectAsState()
|
||||
if (listening || assistantText.isNotBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.72f)),
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center).padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
if (sttText.isBlank()) "слушаю..." else sttText,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (assistantText.isNotBlank()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
assistantText,
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(if (connected) "подключено к телефону" else "нет соединения")
|
||||
Text(status, style = MaterialTheme.typography.bodyMedium)
|
||||
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(messages) { msg ->
|
||||
Text(msg, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||
}
|
||||
|
||||
StatusPanelCompose(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
|
||||
.padding(bottom = 10.dp),
|
||||
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ internal data class PlaybackSnapshot(
|
||||
val playing: Boolean,
|
||||
val itemId: String?,
|
||||
val audioIndex: Int,
|
||||
/** Длительность видео, мс; -1 = неизвестно (плеера нет / ещё не prepared). */
|
||||
val durationMs: Long = -1L,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -239,10 +241,10 @@ class MovieController(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */
|
||||
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex, длительность). Только с главного потока. */
|
||||
internal fun playbackPosition(): PlaybackSnapshot {
|
||||
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex)
|
||||
return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex)
|
||||
return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex, player.duration)
|
||||
}
|
||||
|
||||
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package pw.binom.viewmate.glasses.gestures
|
||||
|
||||
import com.rayneo.arsdk.android.touch.TempleAction
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||
|
||||
/**
|
||||
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
|
||||
*
|
||||
* @NONE — кино: ассистент не вызван.
|
||||
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
|
||||
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
|
||||
* @ANSWER — ответ виден: listening=false и assistantText непуст.
|
||||
*/
|
||||
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
|
||||
|
||||
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
|
||||
data class AssistantState(
|
||||
/** Микрофон открыт (TripleClick → слушаю). */
|
||||
val listening: Boolean = false,
|
||||
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
|
||||
val thinking: Boolean = false,
|
||||
/** Накопленные фразы (SttPhrase, full). */
|
||||
val sttText: String = "",
|
||||
/** Ответ ассистента (ShowText). */
|
||||
val assistantText: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
|
||||
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
|
||||
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
|
||||
*/
|
||||
fun assistantMode(state: AssistantState): AssistantMode = when {
|
||||
state.listening -> AssistantMode.LISTENING
|
||||
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
||||
state.thinking -> AssistantMode.THINKING
|
||||
else -> AssistantMode.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
|
||||
* Матрица (docs/gestures-and-modes.md):
|
||||
*
|
||||
* | Режим | Клик | Двойной | Тройной | Свайпы |
|
||||
* |------------------|--------------------|-----------|---------|----------------------|
|
||||
* | Кино | плей/пауза | игнор | вызов | ±30с |
|
||||
* | Слушаю | отправить фразу | отмена | игнор | игнор |
|
||||
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
|
||||
*/
|
||||
sealed interface GestureDecision {
|
||||
/** Жест в данном режиме не назначен (напр. DoubleClick в кино, свайпы в «слушаю»). */
|
||||
data object Ignore : GestureDecision
|
||||
|
||||
/** TripleClick (кино): пауза фильма + открыть микрофон. */
|
||||
data object CallAssistant : GestureDecision
|
||||
|
||||
/** Клик в кино: плей/пауза (как сегодня, MediaCommand.TOGGLE). */
|
||||
data object TogglePlayback : GestureDecision
|
||||
|
||||
/** Клик в «слушаю»: закрыть микрофон, отправить стоп-STT (фраза на телефон). */
|
||||
data object SendPhrase : GestureDecision
|
||||
|
||||
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
|
||||
data object NewPhrase : GestureDecision
|
||||
|
||||
/**
|
||||
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
|
||||
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
|
||||
* оверлей остаётся на экране даже при пустой истории диалога.
|
||||
* Сторона очков: StopStt(cancel=true) на телефон.
|
||||
*/
|
||||
data object CancelDictation : GestureDecision
|
||||
|
||||
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||
data object ExitMode : GestureDecision
|
||||
|
||||
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
|
||||
data class Seek(val command: MediaCommand, val seekMs: Long) : GestureDecision
|
||||
|
||||
/** Свайпы при «ответе виден»: дельта скролла ответа, px. Положительная — к концу текста. */
|
||||
data class ScrollAnswer(val delta: Float) : GestureDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
|
||||
*
|
||||
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
|
||||
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
|
||||
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
|
||||
* меняем знак здесь, в одной точке).
|
||||
*/
|
||||
fun gestureDecision(
|
||||
action: TempleAction,
|
||||
state: AssistantState,
|
||||
seekStepMs: Long = 30_000,
|
||||
pageDeltaPx: Float = 200f,
|
||||
): GestureDecision = when (val mode = assistantMode(state)) {
|
||||
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
|
||||
AssistantMode.NONE -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.TogglePlayback
|
||||
is TempleAction.TripleClick -> GestureDecision.CallAssistant
|
||||
else ->
|
||||
templeActionToCommand(action, seekStepMs)?.let { GestureDecision.Seek(it.command, it.seekMs) }
|
||||
?: GestureDecision.Ignore
|
||||
}
|
||||
|
||||
AssistantMode.LISTENING -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.SendPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
|
||||
else -> GestureDecision.Ignore
|
||||
}
|
||||
|
||||
AssistantMode.THINKING -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||
else -> GestureDecision.Ignore
|
||||
}
|
||||
|
||||
AssistantMode.ANSWER -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
|
||||
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
|
||||
is TempleAction.SlideContinuous ->
|
||||
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
|
||||
// два строка выше.
|
||||
GestureDecision.ScrollAnswer(action.delta)
|
||||
else -> GestureDecision.Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»):
|
||||
* возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый
|
||||
* текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся
|
||||
* на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true,
|
||||
* т.к. listening=true). Закрытие ассистента — отдельное явное действие:
|
||||
* двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]).
|
||||
* Чистая функция — unit-тесты без Android.
|
||||
*/
|
||||
fun afterCancelDictation(state: AssistantState): AssistantState =
|
||||
state.copy(listening = true, thinking = false, sttText = "")
|
||||
|
||||
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
|
||||
fun overlayVisible(state: AssistantState): Boolean =
|
||||
state.listening || state.thinking || state.assistantText.isNotBlank()
|
||||
|
||||
/**
|
||||
* Заголовок оверлея (над ответом). null — только ответ.
|
||||
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
|
||||
* listening ещё true — микрофон уже закрыт);
|
||||
* - «слушаю...»/фраза — микрофон открыт;
|
||||
* - при «ответе виден» — последняя фраза как контекст (или null).
|
||||
*/
|
||||
fun overlayHeader(state: AssistantState): String? = when {
|
||||
state.thinking && state.assistantText.isBlank() -> "думаю..."
|
||||
state.listening -> state.sttText.ifBlank { "слушаю..." }
|
||||
else -> state.sttText.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка статуса модели Qwen3 для оверлея (показывается, когда модель не READY и
|
||||
* микрофон открыт — распознавание гарантированно пустое): null — модель готова
|
||||
* (ничего не показывать); DOWNLOADING — «качается N%»; MISSING — не скачана;
|
||||
* FAILED — не скачалась (пойдут авто-повторы). Чистая функция — юнит-тесты без Android.
|
||||
*/
|
||||
fun modelStatusLine(status: SttModelStatus): String? = when (status.phase) {
|
||||
SttModelStatus.Phase.READY -> null
|
||||
SttModelStatus.Phase.DOWNLOADING -> "модель качается: ${status.percent}%"
|
||||
SttModelStatus.Phase.MISSING -> "модель не скачана — распознавание недоступно"
|
||||
SttModelStatus.Phase.FAILED -> "модель не скачалась — повторю автоматически"
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
|
||||
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
|
||||
* Чистая функция — unit-тесты без Android.
|
||||
*/
|
||||
fun scrollOffset(currentOffset: Int, delta: Float, maxScroll: Int): Int =
|
||||
(currentOffset + delta).toInt().coerceIn(0, maxScroll)
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -18,6 +19,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pw.binom.viewmate.glasses.GlassesApp
|
||||
|
||||
/**
|
||||
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
|
||||
@@ -36,12 +38,20 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
|
||||
val d = data ?: return
|
||||
val baseStyle = MaterialTheme.typography.bodyMedium
|
||||
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
|
||||
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
|
||||
// + свежее значение от телефона); без видео — ничего не выводить.
|
||||
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
|
||||
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState()
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(Color(0x99000000)) // полупрозрачный чёрный
|
||||
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (movieActive.value && diffMs.value != null) {
|
||||
Text("${diffMs.value} мс", color = Color(0xFF999999), style = style)
|
||||
Spacer(Modifier.width(6.dp * scale))
|
||||
}
|
||||
Text(d.time, color = Color.White, style = style)
|
||||
Spacer(Modifier.width(6.dp * scale))
|
||||
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import pw.binom.asr.qwen3.Qwen3Config
|
||||
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/**
|
||||
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
|
||||
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
|
||||
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
|
||||
* [SttModelDownloader]).
|
||||
*/
|
||||
class GlassesAsr(
|
||||
modelsDir: File,
|
||||
numThreads: Int = 4,
|
||||
) {
|
||||
private val modelsDir = modelsDir
|
||||
private val numThreads = numThreads
|
||||
|
||||
/**
|
||||
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
|
||||
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
|
||||
*/
|
||||
private val recognizer by lazy {
|
||||
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
|
||||
createQwen3Sherpa(
|
||||
Qwen3Config(
|
||||
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
|
||||
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
|
||||
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
|
||||
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
|
||||
numThreads = numThreads,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
||||
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
|
||||
recognizer.recognize(samples, sampleRate)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
import pw.binom.viewmate.core.stt.splitPhrases
|
||||
import pw.binom.viewmate.core.stt.trimEdges
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
|
||||
interface GlassesMic {
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean)
|
||||
fun stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
||||
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
||||
*
|
||||
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
||||
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
|
||||
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
|
||||
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
|
||||
* пусто после 30s-лимита (timeout) — автозавершение.
|
||||
*
|
||||
* @param onResult результат распознавания: text — фактический текст (может быть
|
||||
* пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout).
|
||||
*/
|
||||
class GlassesSttSession(
|
||||
private val filesDir: File,
|
||||
private val scope: CoroutineScope,
|
||||
private val recognizer: (FloatArray) -> String,
|
||||
private val modelReady: () -> Boolean,
|
||||
private val onResult: (text: String, auto: Boolean) -> Unit,
|
||||
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
|
||||
) {
|
||||
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
|
||||
private val sessionLimitMs = 30_000L
|
||||
|
||||
private var mic: GlassesMic? = null
|
||||
|
||||
/**
|
||||
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
|
||||
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
|
||||
*/
|
||||
private val pcmLock = ReentrantLock()
|
||||
private var pcm = ByteArray(0)
|
||||
private var watchdog: Job? = null
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
val isRunning: Boolean get() = running
|
||||
|
||||
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
||||
mic = m
|
||||
m.start(filesDir) { running }
|
||||
watchdog = scope.launch {
|
||||
delay(sessionLimitMs)
|
||||
finish(auto = true)
|
||||
}
|
||||
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
|
||||
}
|
||||
|
||||
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
|
||||
fun send() = finish(auto = false)
|
||||
|
||||
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
|
||||
fun cancel() {
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
log("stt", "STT-сессия очков: отмена")
|
||||
}
|
||||
|
||||
/** Полный стоп (выход из режима ассистента). */
|
||||
fun stop() {
|
||||
running = false
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
log("stt", "STT-сессия очков: закрыта")
|
||||
}
|
||||
|
||||
private fun finish(auto: Boolean) {
|
||||
if (!running) return
|
||||
running = false
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val text = if (audio.isNotEmpty() && modelReady()) {
|
||||
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
|
||||
phrases.joinToString(" ") { recognizer(it) }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
withContext(Dispatchers.Main) { onResult(text, auto) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopMic() {
|
||||
mic?.stop()
|
||||
mic = null
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package pw.binom.viewmate.glasses.stt
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -12,65 +10,112 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
|
||||
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
|
||||
* filesDir/samples (отладка, см. TASK.md этап 2).
|
||||
* Поток микрофона очков (TASK-mic): 16 кГц s16 mono, полный поток БЕЗ VAD-нарезки.
|
||||
*
|
||||
* Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening):
|
||||
* как только очки перестали слушать, поток обрывается.
|
||||
* История: WebRTC VAD резал речь на огрызки — микрофон ARGT78 пишет рвано
|
||||
* (пачки ~50 мс звука / ~50 мс тишины), VAD принимал внутренние провалы за паузы
|
||||
* и ловил лишь ~15% речи. Локальный тест whisper на сыром дампе («1,2,3,4,5
|
||||
* 6,7,8,9,10») распознал полный файл идеально — whisper сам справляется с
|
||||
* рваностью и тихим уровнем (пик ~1500). Поэтому: весь PCM уходит на телефон
|
||||
* чанками по 100 мс, усиление ×3 (без клиппа), телефон копит полный буфер
|
||||
* сессии и шлёт его в whisper по клику.
|
||||
*/
|
||||
class SttMicStream(
|
||||
private val scope: CoroutineScope,
|
||||
private val onChunk: suspend (ByteArray) -> Unit,
|
||||
) {
|
||||
) : GlassesMic {
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
/** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */
|
||||
private var dump: WavWriter? = null
|
||||
|
||||
override fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
job?.cancel()
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
val bufferBytes = maxOf(minBuffer, MIC_CHUNK_FRAMES * FRAME_SIZE * 2)
|
||||
val recorder = runCatching {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
16_000,
|
||||
MIC_SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
6_400,
|
||||
bufferBytes,
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
recorder?.release()
|
||||
log("stt", "микрофон недоступен — отладка: PCM из файла")
|
||||
dump = null
|
||||
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
|
||||
return
|
||||
}
|
||||
|
||||
dump = openLiveMicDump(filesDir)
|
||||
val dumpWriter = dump
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
recorder.startRecording()
|
||||
log("stt", "микрофон запущен (16 кГц, s16, mono)")
|
||||
val buf = ByteArray(3_200)
|
||||
log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)")
|
||||
val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс
|
||||
val chunk = ArrayList<ShortArray>(MIC_CHUNK_FRAMES)
|
||||
while (isActive && shouldRun()) {
|
||||
val n = recorder.read(buf, 0, buf.size)
|
||||
if (n > 0) {
|
||||
val chunk = if (n == buf.size) buf else buf.copyOf(n)
|
||||
onChunk(chunk)
|
||||
val n = recorder.read(frame, 0, FRAME_SIZE) // кадры
|
||||
if (n <= 0) continue
|
||||
val data = if (n == FRAME_SIZE) frame else frame.copyOf(n)
|
||||
// СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки.
|
||||
if (dumpWriter != null) {
|
||||
runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) }
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
}
|
||||
chunk.add(gainShort(data, MIC_GAIN))
|
||||
if (chunk.size >= MIC_CHUNK_FRAMES) {
|
||||
onChunk(shortsToPcm16(chunk))
|
||||
chunk.clear()
|
||||
}
|
||||
}
|
||||
if (chunk.isNotEmpty()) onChunk(shortsToPcm16(chunk)) // остаток < 100 мс
|
||||
} catch (t: Throwable) {
|
||||
log("stt", "микрофон: ${t.message}")
|
||||
} finally {
|
||||
runCatching { recorder.stop() }
|
||||
recorder.release()
|
||||
runCatching { dumpWriter?.close() }
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
log("stt", "микрофон остановлен")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
override fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||
* открыть stt_dump_HHmmss.wav — сырой поток микрофона допишется туда
|
||||
* (эмуляторную ветку не трогаем).
|
||||
*/
|
||||
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||
if (!File(filesDir, "stt_dump.marker").exists()) return null
|
||||
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"))
|
||||
val path = File(filesDir, "stt_dump_$stamp.wav")
|
||||
return runCatching {
|
||||
WavWriter(path).also { log("stt", "dump микрофона: $path") }
|
||||
}
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,6 +151,115 @@ class SttMicStream(
|
||||
}
|
||||
}
|
||||
|
||||
/** Фрейм чтения микрофона: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
|
||||
internal const val FRAME_SIZE = 320
|
||||
|
||||
/** Чанк на телефон: 100 мс = 5 фреймов по 20 мс. */
|
||||
internal const val MIC_CHUNK_FRAMES = 5
|
||||
|
||||
/**
|
||||
* Коэффициент усиления микрофона: ×3 (без клиппа — ARGT78 пишет пик 1–9%, ×3 даёт
|
||||
* 3–27%). Whisper распознал и сырой (пик 1521), и ×3, и ×10 — усиление нужно
|
||||
* только чтобы телефонный whisper.cpp не терял тихие участки.
|
||||
*/
|
||||
internal const val MIC_GAIN = 3f
|
||||
|
||||
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
||||
internal const val CHUNK_BYTES = 3_200
|
||||
|
||||
/** Микрофон очков: 16 кГц — единственная рабочая частота ARGT78 (48 кГц молчит). */
|
||||
internal const val MIC_SAMPLE_RATE = 16_000
|
||||
|
||||
/**
|
||||
* Цифровое усиление фрейма s16le mono с насыщением (для whisper).
|
||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||
*/
|
||||
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
|
||||
if (gain == 1f) return samples
|
||||
val out = ShortArray(samples.size)
|
||||
for (i in samples.indices) {
|
||||
out[i] = (samples[i] * gain).roundToInt().coerceIn(-32_768, 32_767).toShort()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Склейка фреймов (ShortArray) в один s16le ByteArray. */
|
||||
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
||||
var total = 0
|
||||
for (f in frames) total += f.size
|
||||
val out = ByteArray(total * 2)
|
||||
var i = 0
|
||||
for (f in frames) {
|
||||
for (s in f) {
|
||||
out[i++] = (s.toInt() and 0xff).toByte()
|
||||
out[i++] = ((s.toInt() shr 8) and 0xff).toByte()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Даунсэмпл 48→16 кГц (сохранён для тестов; в проде не используется — 16 кГц напрямую):
|
||||
* склейка 48 кГц-фреймов, FIR (скользящее среднее 3) против алиасинга, каждый 3-й сэмпл.
|
||||
*/
|
||||
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
|
||||
var total = 0
|
||||
for (f in frames) total += f.size
|
||||
val outSize = total / 3
|
||||
val out = ShortArray(outSize)
|
||||
var src = 0
|
||||
var dst = 0
|
||||
var prev2 = 0
|
||||
var prev1 = 0
|
||||
for (f in frames) {
|
||||
for (s in f) {
|
||||
if (src % 3 == 2) {
|
||||
val v = ((prev2 + prev1 + s) / 3)
|
||||
if (dst < outSize) out[dst++] = v.toShort()
|
||||
}
|
||||
prev2 = prev1
|
||||
prev1 = s.toInt()
|
||||
src++
|
||||
}
|
||||
}
|
||||
return listOf(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
|
||||
* 16-бит моно: кадр = 2 байта. Байты = frames * 2.
|
||||
* Старый баг (заделан в c940a7e): return read() (кадры) сравнивали с buf.size (байты)
|
||||
* — всегда ложь → copyOf(n) резал кусок пополам (терялись 2-е 50 мс каждого 100 мс).
|
||||
*/
|
||||
internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
|
||||
val bytes = frames * 2
|
||||
return if (bytes == buf.size) buf else buf.copyOf(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Цифровое усиление чанка s16le mono с насыщением.
|
||||
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
|
||||
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
|
||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||
*/
|
||||
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray {
|
||||
if (gain == 1f) return bytes
|
||||
val out = ByteArray(bytes.size)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i + 1 < bytes.size) {
|
||||
val lo = bytes[i].toInt() and 0xff
|
||||
val hi = bytes[i + 1].toInt() and 0xff
|
||||
i += 2
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
val v = (s * gain).roundToInt().coerceIn(-32_768, 32_767)
|
||||
out[j++] = (v and 0xff).toByte()
|
||||
out[j++] = ((v shr 8) and 0xff).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
|
||||
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
|
||||
val out = ByteArray(samples.size * 2)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Модель Qwen3-ASR 0.6B int8 для очек: 4 компонента в [filesDir]/models
|
||||
* (та же иерархия, что на телефоне — `PhoneApp.ensureStt`: conv/encoder/decoder +
|
||||
* tokenizer/). Скачиваются с зеркала [SttModel.BASE_URL] при отсутствии локально:
|
||||
* докачка с места обрыва (Range), проверка SHA-256, сквозной прогресс.
|
||||
*
|
||||
* Зеркало — внутренний `static.binom.pw/models/qwen3_06/` (обычная иерархия
|
||||
* корневых .onnx + подкаталог tokenizer/), URL зашит константой.
|
||||
*/
|
||||
object SttModel {
|
||||
/** Корень зеркала модели: обычные имена файлов + подкаталог tokenizer/. */
|
||||
const val BASE_URL = "https://static.binom.pw/models/qwen3_06"
|
||||
|
||||
/** Иерархия модели + размер и SHA-256 каждого файла (контроль целостности). */
|
||||
internal val FILES: List<ModelFile> = listOf(
|
||||
ModelFile("conv_frontend.onnx", 44_148_281L, "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"),
|
||||
ModelFile("encoder.int8.onnx", 182_491_662L, "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"),
|
||||
ModelFile("decoder.int8.onnx", 755_914_231L, "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"),
|
||||
ModelFile("tokenizer/merges.txt", 1_671_853L, "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"),
|
||||
ModelFile("tokenizer/vocab.json", 2_776_833L, "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"),
|
||||
ModelFile("tokenizer/tokenizer_config.json", 12_487L, "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Один файл модели: относительный путь (в models/), размер и SHA-256. */
|
||||
internal data class ModelFile(
|
||||
val relPath: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256Hex: String,
|
||||
)
|
||||
|
||||
/** Быстрый «довольны ли файлом»: есть + размер совпадает (SHA проверяем на докатке). */
|
||||
internal fun isSatisfied(modelsDir: File, f: ModelFile): Boolean {
|
||||
val final = File(modelsDir, f.relPath)
|
||||
return final.isFile && final.length() == f.sizeBytes
|
||||
}
|
||||
|
||||
/** Какие файлы [files] нужны (отсутствуют или битые по размеру). */
|
||||
internal fun whichFilesNeeded(modelsDir: File, files: List<ModelFile>): List<ModelFile> =
|
||||
files.filter { !isSatisfied(modelsDir, it) }
|
||||
|
||||
/** Модель на диске полностью: все файлы [files] на месте с верным размером. */
|
||||
internal fun isModelComplete(modelsDir: File, files: List<ModelFile> = SttModel.FILES): Boolean =
|
||||
whichFilesNeeded(modelsDir, files).isEmpty()
|
||||
|
||||
/** Все байты всех файлов модели (для сквозного процента). */
|
||||
internal fun modelTotalBytes(files: List<ModelFile> = SttModel.FILES): Long = files.sumOf { it.sizeBytes }
|
||||
|
||||
/** SHA-256 файла (потоком, без загрузки всего в память). */
|
||||
internal fun sha256Hex(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
digest.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
/** Статус модели: что происходит со скачиванием (для индикатора в UI очков). */
|
||||
data class SttModelStatus(
|
||||
val phase: Phase,
|
||||
val percent: Int = 0,
|
||||
val detail: String = "",
|
||||
) {
|
||||
enum class Phase { MISSING, DOWNLOADING, READY, FAILED }
|
||||
|
||||
val ready: Boolean get() = phase == Phase.READY
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание модели Qwen3-ASR в [modelsDir] (filesDir/models). Прогресс — в [status].
|
||||
* Идиомпотентно: [ensure] не делает ничего, если модель уже на месте; иначе докачивает
|
||||
* недостающие/битые файлы с Range и проверяет SHA-256.
|
||||
*/
|
||||
class SttModelDownloader(
|
||||
private val modelsDir: File,
|
||||
private val scope: CoroutineScope,
|
||||
private val okHttp: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // большие файлы, паузы в потоке
|
||||
.build(),
|
||||
) {
|
||||
private val _status = MutableStateFlow(SttModelStatus(SttModelStatus.Phase.MISSING))
|
||||
val status: StateFlow<SttModelStatus> = _status.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var downloadJob: Job? = null
|
||||
|
||||
/** Таймер авто-повтора после ошибки (сеть могла появиться позже — см. [scheduleRetry]). */
|
||||
@Volatile
|
||||
private var retryJob: Job? = null
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Пауза до авто-повтора скачивания после FAILED: сеть могла появиться позже. */
|
||||
private const val RETRY_DELAY_MS = 15_000L
|
||||
}
|
||||
|
||||
/** Перечитать с диска: READY (готово) или MISSING (не хватает). */
|
||||
fun refresh() {
|
||||
_status.value = if (isModelComplete(modelsDir)) {
|
||||
SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||
} else {
|
||||
SttModelStatus(SttModelStatus.Phase.MISSING, overallPercent(), "нет локально — скачаю при старте")
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачать недостающее (idempotent; повторный вызов пока идёт — игнор). */
|
||||
fun ensure() {
|
||||
if (isModelComplete(modelsDir)) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
if (downloadJob?.isActive == true) return
|
||||
downloadJob = scope.launch {
|
||||
try {
|
||||
val total = modelTotalBytes()
|
||||
val needed = whichFilesNeeded(modelsDir, SttModel.FILES)
|
||||
var done = total - needed.sumOf { it.sizeBytes } // байты уже готовых файлов
|
||||
needed.forEach { f ->
|
||||
currentCoroutineContext().ensureActive()
|
||||
downloadOne(f) { fileDone ->
|
||||
val overall = ((done + fileDone) * 100 / total).toInt().coerceIn(0, 100)
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.DOWNLOADING, overall, "${f.relPath}: $overall%")
|
||||
}
|
||||
done += f.sizeBytes
|
||||
log("stt", "модель: скачан ${f.relPath} (${f.sizeBytes} Б)")
|
||||
}
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||
log("stt", "модель Qwen3-ASR готова в ${modelsDir.absolutePath}")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("stt", "ошибка скачивания модели: ${e.message}")
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.FAILED, overallPercent(), "ошибка: ${e.message}")
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-повтор после [SttModelStatus.Phase.FAILED]: сеть могла появиться позже
|
||||
* (Wi-Fi очков выключен при старте, DNS не резолвится — фиксированные случаи).
|
||||
* Один таймер: новый провал перезапускает его; при готовности/идущем скачивании
|
||||
* [ensure] — no-op, так что повторы сами завершаются.
|
||||
*/
|
||||
private fun scheduleRetry() {
|
||||
retryJob?.cancel()
|
||||
retryJob = scope.launch {
|
||||
delay(RETRY_DELAY_MS)
|
||||
log("stt", "модель: авто-повтор скачивания (ошибка могла быть сессионной — сеть)")
|
||||
ensure()
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачать один файл: Range-докачка в .part, SHA-256, rename в финальный. onProgress — байты файла. */
|
||||
private suspend fun downloadOne(f: ModelFile, onProgress: (Long) -> Unit) {
|
||||
val parent = File(modelsDir, f.relPath).parentFile
|
||||
parent?.mkdirs()
|
||||
val final = File(modelsDir, f.relPath)
|
||||
val part = File(modelsDir, "$f.relPath.part")
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
|
||||
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
|
||||
if (existing > 0) builder.header("Range", "bytes=$existing-")
|
||||
|
||||
okHttp.newCall(builder.build()).execute().use { response ->
|
||||
val code = response.code
|
||||
if (code != 200 && code != 206) error("HTTP $code для ${f.relPath}")
|
||||
val body = response.body
|
||||
|
||||
val resumed = code == 206 && existing > 0
|
||||
val input = body.byteStream()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
var done = if (resumed) existing else 0L
|
||||
onProgress(done)
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
onProgress(done)
|
||||
}
|
||||
}
|
||||
|
||||
if (sha256Hex(part) != f.sha256Hex) {
|
||||
part.delete()
|
||||
error("SHA-256 не совпал для ${f.relPath}")
|
||||
}
|
||||
if (final.exists()) final.delete()
|
||||
if (!part.renameTo(final)) {
|
||||
part.copyTo(final, overwrite = true)
|
||||
part.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сквозной процент: сколько всех байтов модели уже на диске (по размеру). */
|
||||
private fun overallPercent(): Int {
|
||||
val total = modelTotalBytes()
|
||||
if (total <= 0) return 0
|
||||
val have = SttModel.FILES.filter { isSatisfied(modelsDir, it) }.sumOf { it.sizeBytes }
|
||||
return (have * 100 / total).toInt().coerceIn(0, 100)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Минимальный WAV-райтер (PCM s16le): 44-байтовый RIFF-заголовок пишется один раз
|
||||
* при создании файла, [write] дописывает чанки, на close в заголовок дописываются
|
||||
* реальные размеры (best-effort — при сбое ридер читает файл до EOF, размеры
|
||||
* 0xFFFFFFFF — конвенция стриминговых WAV).
|
||||
*
|
||||
* Не потокобезопасен — вызывать из одного потока (поток микрофона).
|
||||
*/
|
||||
class WavWriter(
|
||||
private val path: File,
|
||||
private val sampleRate: Int = 16_000,
|
||||
private val channels: Int = 1,
|
||||
) {
|
||||
private val out: FileOutputStream
|
||||
private var dataBytes = 0L
|
||||
private var closed = false
|
||||
|
||||
init {
|
||||
out = FileOutputStream(path)
|
||||
out.write(headerBytes())
|
||||
}
|
||||
|
||||
/** Дописать чанк s16le PCM (16 кГц, mono). */
|
||||
fun write(pcm: ByteArray) {
|
||||
out.write(pcm)
|
||||
dataBytes += pcm.size
|
||||
}
|
||||
|
||||
/** Закрыть файл и прописать реальные размеры в заголовок (идемпотентно). */
|
||||
fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
out.close()
|
||||
runCatching {
|
||||
RandomAccessFile(path, "rw").use { raf ->
|
||||
raf.seek(4)
|
||||
raf.write(leInt(36 + dataBytes)) // размер RIFF (little-endian)
|
||||
raf.seek(40)
|
||||
raf.write(leInt(dataBytes)) // размер data (little-endian)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 4 байта little-endian (WAV-заголовок), в отличие от RandomAccessFile.writeInt (big-endian). */
|
||||
private fun leInt(v: Long): ByteArray =
|
||||
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()
|
||||
|
||||
private fun headerBytes(): ByteArray =
|
||||
ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
put("RIFF".toByteArray())
|
||||
putInt(-1) // placeholder
|
||||
put("WAVE".toByteArray())
|
||||
put("fmt ".toByteArray())
|
||||
putInt(16)
|
||||
putShort(1.toShort()) // PCM
|
||||
putShort(channels.toShort())
|
||||
putInt(sampleRate)
|
||||
putInt(sampleRate * channels * 2)
|
||||
putShort((channels * 2).toShort())
|
||||
putShort(16.toShort())
|
||||
put("data".toByteArray())
|
||||
putInt(-1) // placeholder
|
||||
}.array()
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pw.binom.viewmate.glasses.ChatEntry
|
||||
import pw.binom.viewmate.glasses.GlassesApp
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||
import pw.binom.viewmate.glasses.gestures.modelStatusLine
|
||||
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||
|
||||
/**
|
||||
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
|
||||
* каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
|
||||
* «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
|
||||
* в том же scale-контейнере, что у экранов. Режим общения явный
|
||||
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
|
||||
* Состояния: «слушаю»/«думаю...» — заголовок,
|
||||
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||
* [GlassesApp.answerScrollDelta]).
|
||||
*/
|
||||
@Composable
|
||||
fun AssistantOverlay() {
|
||||
val app = GlassesApp.instance
|
||||
val listening by app.listening.collectAsState()
|
||||
val thinking by app.thinking.collectAsState()
|
||||
val sttText by app.sttText.collectAsState()
|
||||
val assistantText by app.assistantText.collectAsState()
|
||||
val chatHistory by app.chatHistory.collectAsState()
|
||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||
if (!overlayVisible(state)) return
|
||||
val modelStatus by app.sttModel.status.collectAsState()
|
||||
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
|
||||
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
|
||||
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null
|
||||
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||
val scrollState = rememberScrollState()
|
||||
val hasHistory = chatHistory.isNotEmpty()
|
||||
|
||||
// Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению.
|
||||
LaunchedEffect(chatHistory.size, state.assistantText) {
|
||||
if (hasHistory || state.assistantText.isNotBlank()) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
|
||||
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
||||
LaunchedEffect(Unit) {
|
||||
app.answerScrollDelta.collect { pending ->
|
||||
if (pending == 0f) return@collect
|
||||
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
||||
app.answerScrollDelta.value = 0f
|
||||
if (target != scrollState.value) {
|
||||
scrollState.animateScrollTo(target, tween(150))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overlayBox(app, state, chatHistory, scrollState, modelLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* Поглазник: затемняющий слой накрывает ВСЁ поле глаза (100% viewport)
|
||||
* непрозрачным чёрным — поверх SurfaceView-видео и любого положения регулятора
|
||||
* «Высота» (TZ-ui-four п.4: 72%-полупрозрачный слой поверх контейнера видео
|
||||
* оставался «проглядывающим» — видео светилось/не затемнялось). Контент
|
||||
* (текст/редактирование) остаётся в том же scale/offset-контейнере, что у экранов.
|
||||
*/
|
||||
@Composable
|
||||
private fun overlayBox(
|
||||
app: GlassesApp,
|
||||
state: AssistantState,
|
||||
chatHistory: List<ChatEntry>,
|
||||
scrollState: ScrollState,
|
||||
modelLine: String?,
|
||||
) {
|
||||
Binocular(scale = false) {
|
||||
val scale by app.scale.collectAsState()
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
val side = BinocularScreenSide.current
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(
|
||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
||||
),
|
||||
) {
|
||||
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
|
||||
|
||||
if (state.listening) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 12.dp, end = 12.dp)
|
||||
.size(16.dp)
|
||||
.background(Color(0xFFFF3B30), CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantOverlayContent(
|
||||
state: AssistantState,
|
||||
chatHistory: List<ChatEntry>,
|
||||
scrollState: ScrollState,
|
||||
modelLine: String?,
|
||||
) {
|
||||
val hasHistory = chatHistory.isNotEmpty()
|
||||
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
||||
val header = overlayHeader(state)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center,
|
||||
) {
|
||||
if (!header.isNullOrBlank()) {
|
||||
Text(
|
||||
text = header,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
// Строка статуса модели (только «слушаю» + модель не READY) — объясняет, почему
|
||||
// распознавание сейчас не работает и что происходит (качается / не скачалась).
|
||||
if (!modelLine.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = modelLine,
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
if (hasHistory) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
chatHistory.forEach { entry ->
|
||||
Text(
|
||||
text = if (entry.role == "user") "я: ${entry.text}" else "ассистент: ${entry.text}",
|
||||
color = if (entry.role == "user") Color.White else Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Start,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (fallbackAnswer) {
|
||||
// Старый клиент телефона не шлёт историю: показываем одиночный ответ.
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = "ассистент: ${state.assistantText}",
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Start,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ fun GlassesMovieScreen(
|
||||
var videoHeight by remember { mutableIntStateOf(480) }
|
||||
|
||||
val scale by GlassesApp.instance.scale.collectAsState()
|
||||
val vertical by GlassesApp.instance.vertical.collectAsState()
|
||||
|
||||
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
|
||||
// глаз из Binocular) регистрируют в нём свои SurfaceView.
|
||||
@@ -103,34 +104,47 @@ fun GlassesMovieScreen(
|
||||
maxHeight = containerH - 25,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(Color.Black),
|
||||
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
|
||||
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(
|
||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
|
||||
),
|
||||
) {
|
||||
if (SCALE_DEBUG_SQUARE) {
|
||||
// Тестовый режим: большой красный квадрат вместо видео — быстрая
|
||||
// проверка масштаба/позиций скрином без запуска видео.
|
||||
Box(Modifier.fillMaxSize().background(Color.Red))
|
||||
} else {
|
||||
BinocularVideoPlayer(
|
||||
player = player,
|
||||
renderer = renderer,
|
||||
modifier = Modifier.size(playerWidth.px, playerHeight.px),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(Color.Black),
|
||||
// Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
|
||||
// Row-центрирование съедало вертикаль — картинка оставалась «по центру».
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
if (SCALE_DEBUG_SQUARE) {
|
||||
// Тестовый режим: большой красный квадрат вместо видео — быстрая
|
||||
// проверка масштаба/позиций скрином без запуска видео.
|
||||
Box(Modifier.fillMaxSize().background(Color.Red))
|
||||
} else {
|
||||
BinocularVideoPlayer(
|
||||
player = player,
|
||||
renderer = renderer,
|
||||
modifier = Modifier
|
||||
.size(playerWidth.px, playerHeight.px)
|
||||
.offset(y = videoOffsetY(containerH, playerHeight, vertical).px),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
|
||||
// своей половины, бинокулярно сливается в одну по центру.
|
||||
StatusPanelCompose(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
scale = scale,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
|
||||
.padding(bottom = 10.dp),
|
||||
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -143,6 +157,30 @@ fun GlassesMovieScreen(
|
||||
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
|
||||
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
|
||||
|
||||
/**
|
||||
* Вертикальное смещение контейнера глаза (с учётом масштаба):
|
||||
* vertical = 1.0 — у верха (как всегда было); 0.0 — нижняя кромка контейнера
|
||||
* прижата к низу экрана. Свободный ход по вертикали = (1 - scale) высоты.
|
||||
*/
|
||||
fun verticalOffsetY(screenHeightPx: Int, scale: Float, vertical: Float): Int =
|
||||
((1f - vertical) * screenHeightPx * (1f - scale)).toInt()
|
||||
|
||||
/**
|
||||
* Смещение видео внутри контейнера глаза: vertical=1.0 — у верха контейнера,
|
||||
* 0.0 — у низа (нижняя кромка видео прижата к низу контейнера, который сам
|
||||
* у низа экрана при vertical=0). Row-центрирование иначе съедало вертикаль.
|
||||
*/
|
||||
fun videoOffsetY(containerHeightPx: Int, videoHeightPx: Int, vertical: Float): Int =
|
||||
((1f - vertical) * (containerHeightPx - videoHeightPx).coerceAtLeast(0)).toInt()
|
||||
|
||||
/**
|
||||
* Горизонтальный сдвиг панели статуса к центру (к носу), чтобы её центр совпадал
|
||||
* с центром контейнера при любом масштабе: LEFT — вправо, RIGHT — влево,
|
||||
* на половину хода контейнера ((1 - scale) * W / 2).
|
||||
*/
|
||||
fun panelOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
|
||||
((1f - scale) * screenWidthPx / 2f).toInt() * if (side == Side.LEFT) 1 else -1
|
||||
|
||||
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
|
||||
private const val SCALE_DEBUG_SQUARE = false
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.viewmate.glasses.ui.Side
|
||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||
import pw.binom.viewmate.glasses.ui.verticalOffsetY
|
||||
import pw.binom.viewmate.glasses.ui.videoOffsetY
|
||||
import pw.binom.viewmate.glasses.ui.panelOffsetX
|
||||
|
||||
/**
|
||||
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
|
||||
@@ -34,4 +37,81 @@ class ScaleOffsetTest {
|
||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
|
||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
|
||||
}
|
||||
|
||||
/*
|
||||
* verticalOffsetY — вертикальное положение контейнера с учётом масштаба:
|
||||
* 1.0 — у верха (offset 0), 0.0 — нижняя кромка у низа ((1 - scale) высоты).
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun verticalTop_isZero() {
|
||||
assertEquals(0, verticalOffsetY(480, 1f, 1f))
|
||||
assertEquals(0, verticalOffsetY(480, 0.7f, 1f))
|
||||
assertEquals(0, verticalOffsetY(480, 0.5f, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verticalBottom_pushesToBottomEdge() {
|
||||
// 0.7 scale: свободный ход = 480 * 0.3 = 144
|
||||
assertEquals(144, verticalOffsetY(480, 0.7f, 0f))
|
||||
// 1.0 scale: хода нет — экран и так на весь дисплей
|
||||
assertEquals(0, verticalOffsetY(480, 1f, 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verticalMiddle_halfOfFreeRun() {
|
||||
// 0.7 scale, vertical 0.5: (1-0.5)*480*0.3 = 72
|
||||
assertEquals(72, verticalOffsetY(480, 0.7f, 0.5f))
|
||||
assertEquals(0, verticalOffsetY(480, 1f, 0.5f))
|
||||
}
|
||||
|
||||
/*
|
||||
* videoOffsetY — видео внутри контейнера: верх при vertical=1, низ при 0.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun videoTop_zero() {
|
||||
assertEquals(0, videoOffsetY(432, 268, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoBottom_pushedToContainerBottom() {
|
||||
// 432 контейнер, 268 видео: ход = 164
|
||||
assertEquals(164, videoOffsetY(432, 268, 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoMiddle_halfOfRun() {
|
||||
assertEquals(82, videoOffsetY(432, 268, 0.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoLargerThanContainer_noNegativeOffset() {
|
||||
assertEquals(0, videoOffsetY(200, 268, 0f), "видео выше контейнера — хода нет")
|
||||
}
|
||||
|
||||
/*
|
||||
* panelOffsetX — панель статуса сдвигается к носу, как контейнер:
|
||||
* LEFT — вправо, RIGHT — влево, на (1 - scale) * W / 2.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun panelFullScale_noOffset() {
|
||||
assertEquals(0, panelOffsetX(Side.LEFT, 640, 1f))
|
||||
assertEquals(0, panelOffsetX(Side.RIGHT, 640, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun panelHalfScale_movesTowardNose() {
|
||||
// 640 * 0.5 / 2 = 160: LEFT вправо, RIGHT влево
|
||||
assertEquals(160, panelOffsetX(Side.LEFT, 640, 0.5f))
|
||||
assertEquals(-160, panelOffsetX(Side.RIGHT, 640, 0.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun panelScale07_offset() {
|
||||
// 640 * 0.3 / 2 = 96
|
||||
assertEquals(96, panelOffsetX(Side.LEFT, 640, 0.7f))
|
||||
assertEquals(-96, panelOffsetX(Side.RIGHT, 640, 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package pw.binom.viewmate.glasses.gestures
|
||||
|
||||
import android.view.MotionEvent
|
||||
import com.rayneo.arsdk.android.touch.FlingArgs
|
||||
import com.rayneo.arsdk.android.touch.TempleAction
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.Click
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.DoubleClick
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.SlideBackward
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.SlideContinuous
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.SlideForward
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.TripleClick
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||
|
||||
/**
|
||||
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
||||
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
|
||||
*/
|
||||
class AssistantGesturesTest {
|
||||
|
||||
private val movie = AssistantState()
|
||||
private val listening = AssistantState(listening = true)
|
||||
private val thinking = AssistantState(thinking = true)
|
||||
private val answer = AssistantState(assistantText = "ответ")
|
||||
|
||||
// ————— режим по состоянию —————
|
||||
|
||||
@Test
|
||||
fun movieStateIsNone() {
|
||||
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
||||
}
|
||||
|
||||
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
|
||||
@Test
|
||||
fun listeningWinsOverEverything() {
|
||||
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
|
||||
assertEquals(AssistantMode.LISTENING, assistantMode(s))
|
||||
}
|
||||
|
||||
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
|
||||
@Test
|
||||
fun answerWinsOverThinking() {
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkingOnlyWhenNoAnswer() {
|
||||
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
|
||||
}
|
||||
|
||||
// ————— кино ( NONE) —————
|
||||
|
||||
@Test
|
||||
fun movieMatrix() {
|
||||
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), movie))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
|
||||
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
|
||||
assertEquals(
|
||||
GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 30_000),
|
||||
gestureDecision(slideForward(), movie),
|
||||
)
|
||||
assertEquals(
|
||||
GestureDecision.Seek(MediaCommand.SEEK_BACKWARD, 30_000),
|
||||
gestureDecision(slideBackwardAction(), movie),
|
||||
)
|
||||
// Свайп «плавного» типа без ответа не управляет скроллом → игнор.
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movieSeekStepIsParameter() {
|
||||
val d = gestureDecision(slideForward(), movie, seekStepMs = 10_000)
|
||||
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
|
||||
}
|
||||
|
||||
// ————— слушаю —————
|
||||
|
||||
@Test
|
||||
fun listeningMatrix() {
|
||||
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
|
||||
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
|
||||
}
|
||||
|
||||
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
|
||||
|
||||
@Test
|
||||
fun thinkingMatrix() {
|
||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), thinking))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), thinking))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), thinking))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), thinking))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
|
||||
}
|
||||
|
||||
// ————— «ответ виден» —————
|
||||
|
||||
@Test
|
||||
fun answerMatrix() {
|
||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
|
||||
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
|
||||
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun answerPageDeltaIsParameter() {
|
||||
assertEquals(
|
||||
GestureDecision.ScrollAnswer(80f),
|
||||
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
|
||||
)
|
||||
}
|
||||
|
||||
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
|
||||
|
||||
@Test
|
||||
fun sessionTransitions() {
|
||||
var s = movie
|
||||
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
|
||||
s = s.copy(listening = true)
|
||||
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
|
||||
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
|
||||
assertEquals(AssistantMode.THINKING, assistantMode(s))
|
||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||
s = s.copy(thinking = false, assistantText = "ответ")
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(s))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||
// Выход: оверлей невидим, кино снова управляется кликом.
|
||||
val exited = s.copy(assistantText = "")
|
||||
assertFalse(overlayVisible(exited))
|
||||
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
||||
}
|
||||
|
||||
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
|
||||
@Test
|
||||
fun cancelStaysInConversation() {
|
||||
val decision = gestureDecision(DoubleClick(), listening)
|
||||
assertEquals(GestureDecision.CancelDictation, decision)
|
||||
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
|
||||
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
|
||||
val after = afterCancelDictation(listening)
|
||||
assertEquals(AssistantMode.LISTENING, assistantMode(after))
|
||||
assertTrue(overlayVisible(after))
|
||||
// Двойной тап в «слушаю» после отмены — снова просто отмена новой записи.
|
||||
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), after))
|
||||
// Отмена посреди диалога: пользователь говорит новую фразу → ответ виден
|
||||
// (listening уже закрыт отправкой) — выход (как и раньше) DoubleClick.
|
||||
val afterWithAnswer = after.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(afterWithAnswer))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), afterWithAnswer))
|
||||
}
|
||||
|
||||
/** Отмена первой записи (пустая история): оверлей НЕ закрывается (TZ-ui-four п.1). */
|
||||
@Test
|
||||
fun cancelFirstRecordingKeepsOverlay() {
|
||||
// Чистый «кино»-снимок → «слушаю» (вызов ассистента) → отмена записи.
|
||||
val listeningNow = movie.copy(listening = true)
|
||||
val cancelled = afterCancelDictation(listeningNow)
|
||||
assertTrue(cancelled.assistantText.isBlank())
|
||||
assertTrue(cancelled.listening)
|
||||
// Оверлей виден, хотя ни фразы, ни ответа нет — «слушай готов».
|
||||
assertTrue(overlayVisible(cancelled))
|
||||
// И только явный выход (после диалога) прячет оверлей.
|
||||
val exited = cancelled.copy(listening = false, thinking = false, assistantText = "ответ")
|
||||
val closed = exited.copy(listening = false, thinking = false, assistantText = "")
|
||||
assertFalse(overlayVisible(closed))
|
||||
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), closed))
|
||||
}
|
||||
|
||||
/** Отмена сбрасывает накопленный текст текущей (отменяемой) записи. */
|
||||
@Test
|
||||
fun cancelDictationClearsCancelledText() {
|
||||
val after = afterCancelDictation(listening.copy(sttText = "слова до отмены"))
|
||||
assertEquals("", after.sttText)
|
||||
assertTrue(after.listening)
|
||||
}
|
||||
|
||||
// ————— оверлей —————
|
||||
|
||||
@Test
|
||||
fun overlayInvisibleInMovie() {
|
||||
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
|
||||
assertFalse(overlayVisible(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlayVisibleInListeningThinkingAndAnswer() {
|
||||
assertTrue(overlayVisible(listening))
|
||||
assertTrue(overlayVisible(thinking))
|
||||
assertTrue(overlayVisible(answer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkingHeaderIsDotDotDot() {
|
||||
assertEquals("думаю...", overlayHeader(thinking))
|
||||
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
|
||||
assertEquals(
|
||||
"думаю...",
|
||||
overlayHeader(AssistantState(listening = true, thinking = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeningHeaderFallsBackToListening() {
|
||||
assertEquals("слушаю...", overlayHeader(listening))
|
||||
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun answerHeaderIsLastPhraseOrNull() {
|
||||
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
|
||||
assertNull(overlayHeader(answer))
|
||||
assertNull(overlayHeader(movie))
|
||||
}
|
||||
|
||||
// ————— строка статуса модели —————
|
||||
|
||||
@Test
|
||||
fun modelStatusLineReadyIsNull() {
|
||||
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelStatusLineDownloadingShowsPercent() {
|
||||
assertEquals(
|
||||
"модель качается: 45%",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelStatusLineMissingAndFailed() {
|
||||
assertEquals(
|
||||
"модель не скачана — распознавание недоступно",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
|
||||
)
|
||||
assertEquals(
|
||||
"модель не скачалась — повторю автоматически",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
|
||||
)
|
||||
}
|
||||
|
||||
// ————— скролл ответа —————
|
||||
|
||||
@Test
|
||||
fun scrollOffsetAppliesDelta() {
|
||||
assertEquals(342, scrollOffset(132, 210f, 10_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollOffsetClampsAboveMax() {
|
||||
assertEquals(1000, scrollOffset(800, 1000f, 1000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollOffsetClampsBelowZero() {
|
||||
assertEquals(0, scrollOffset(100, -500f, 1000))
|
||||
}
|
||||
|
||||
/**
|
||||
* Float-дельта округляется вниз (к целой части), а не к ближайшему.
|
||||
* (10.7f в IEEE754 ~ 10.700000763, поэтому 100 - 10.7f = 89.2999… → 89.)
|
||||
*/
|
||||
@Test
|
||||
fun scrollOffsetTruncatesFractionalDelta() {
|
||||
assertEquals(112, scrollOffset(100, 12.7f, 10_000))
|
||||
assertEquals(89, scrollOffset(100, -10.7f, 10_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollOffsetWithZeroMax() {
|
||||
assertEquals(0, scrollOffset(5, 100f, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK-жесты в юнит-тестах: MotionEvent создаётся через sun.misc.Unsafe
|
||||
* (allocateInstance, без конструктора — в JVM-тестах Android-классы «not mocked»).
|
||||
* Как в GestureCommandsTest.
|
||||
*/
|
||||
private fun fakeMotionEvent(): MotionEvent {
|
||||
val unsafeClass = Class.forName("sun.misc.Unsafe")
|
||||
val theUnsafe = unsafeClass.getDeclaredField("theUnsafe")
|
||||
theUnsafe.isAccessible = true
|
||||
val unsafe = theUnsafe.get(null)
|
||||
val allocate = unsafeClass.getMethod("allocateInstance", Class::class.java)
|
||||
return allocate.invoke(unsafe, MotionEvent::class.java) as MotionEvent
|
||||
}
|
||||
|
||||
private fun unsafeFlingArgs(): FlingArgs {
|
||||
val me1 = fakeMotionEvent()
|
||||
val me2 = fakeMotionEvent()
|
||||
return FlingArgs(me1, me2, 1f, 1f)
|
||||
}
|
||||
|
||||
private fun slideForward() = SlideForward(unsafeFlingArgs())
|
||||
|
||||
private fun slideBackwardAction() = SlideBackward(unsafeFlingArgs())
|
||||
|
||||
/** SlideContinuous: дельта шлейфа; second — флаг long-жеста (на тест не влияет). */
|
||||
private fun slideContinuous(delta: Float = 9f) = SlideContinuous(delta, false)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotSame
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
|
||||
* 16-бит моно: кадр = 2 байта. Старики брали return read() как байты —
|
||||
* и copyOf(кадры) резал каждый 100-мс кусок пополам (терялись 2-е 50 мс).
|
||||
*/
|
||||
class MicChunkTest {
|
||||
|
||||
@Test
|
||||
fun fullChunk_all1600Frames_returnsAll3200Bytes() {
|
||||
val buf = ByteArray(CHUNK_BYTES)
|
||||
val out = micChunkBytes(buf, CHUNK_BYTES / 2) // 1600 кадров = полная 100 мс
|
||||
assertEquals(CHUNK_BYTES, out.size, "полный чанк = 3200 байт (100 мс), а не 1600")
|
||||
assertSame(buf, out, "полный чанк не копируется — реюзаем buf без аллокаций")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortRead_800Frames_gives1600Bytes_not800() {
|
||||
val buf = ByteArray(CHUNK_BYTES)
|
||||
val out = micChunkBytes(buf, 800) // 800 кадров = 1600 байт
|
||||
assertEquals(1_600, out.size, "байты = кадры × 2, не равны количеству кадров")
|
||||
assertNotSame(buf, out, "короткий чанк копируется, иначе хвост buf — мусор")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldLogic_lostSecondHalf_ofEachChunk() {
|
||||
// Задокументированный баг: n (кадры) брался как байты.
|
||||
val buf = ByteArray(CHUNK_BYTES)
|
||||
val frames = recorderWouldReturn(1600) // полный 100-мс запрос вернул 1600 кадров
|
||||
val oldChunk = if (frames == buf.size) buf else buf.copyOf(frames) // СТАРОЕ
|
||||
assertEquals(1_600, oldChunk.size, "старая логика теряла 2-ю половину куска — отсюда «как через один»")
|
||||
val newChunk = micChunkBytes(buf, frames)
|
||||
assertEquals(3_200, newChunk.size, "новая логика отдаёт полные 100 мс")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun framesRequested_forFull100MsIs1600() {
|
||||
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)")
|
||||
}
|
||||
|
||||
/*
|
||||
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
|
||||
* gainChunk ×5 поднимает речь для whisper; VAD детектит на сыром сигнале.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun quietChunk_gained3_peakReachesAtLeast2700_sizeKept() {
|
||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
|
||||
val out = gainChunk(chunk)
|
||||
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
||||
val peak = s16Samples(out).maxOf { abs(it) }
|
||||
assertTrue(peak >= 2_700, "тихий пик 1000 × 3 должен дать ≥ 2700, а не $peak")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loudChunk_gained3_clampedInsideS16Range() {
|
||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
|
||||
val out = gainChunk(chunk)
|
||||
val s = s16Samples(out)
|
||||
assertEquals(32_767, s.max(), "30000 × 3 = 90000 → насыщение до 32767")
|
||||
assertEquals(-32_768, s.min(), "-30000 × 3 = -90000 → насыщение до -32768")
|
||||
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroChunk_gained3_bytesUnchanged() {
|
||||
val chunk = ByteArray(CHUNK_BYTES)
|
||||
val out = gainChunk(chunk)
|
||||
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
||||
assertTrue(out.all { it == 0.toByte() }, "нули остаются нулями после усиления")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gainEqualsOne_returnsSameInstance_noReallocation() {
|
||||
val chunk = s16leChunk(1_600) { j -> (j % 3_001) - 1_500 }
|
||||
assertSame(chunk, gainChunk(chunk, 1f), "gain = 1: не аллоцируем заново — возвратим сами bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
||||
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
||||
val s = s16Samples(gainChunk(chunk))
|
||||
assertEquals(-369, s[0], "первый сэмпл: знак сохранён и усилен ×3")
|
||||
assertEquals(1_368, s[1], "второй сэмпл: порядок сохранён и усилен ×3")
|
||||
}
|
||||
|
||||
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
||||
private fun s16leChunk(n: Int, make: (Int) -> Int): ByteArray {
|
||||
val out = ByteArray(n * 2)
|
||||
for (j in 0 until n) {
|
||||
val v = make(j)
|
||||
out[j * 2] = (v and 0xff).toByte()
|
||||
out[j * 2 + 1] = ((v shr 8) and 0xff).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** s16le ByteArray → IntArray сэмплов (разбор как в s16leToFloat app-phone). */
|
||||
private fun s16Samples(bytes: ByteArray): IntArray {
|
||||
val n = bytes.size / 2
|
||||
val out = IntArray(n)
|
||||
for (j in 0 until n) {
|
||||
val lo = bytes[j * 2].toInt() and 0xff
|
||||
val hi = bytes[j * 2 + 1].toInt() and 0xff
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
out[j] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun recorderWouldReturn(frames: Int) = frames
|
||||
|
||||
/*
|
||||
* VAD на очках (схема pair-view): фреймы 20 мс (320 сэмплов @ 16 кГц),
|
||||
* усиление ×10 перед VAD и whisper, склейка речевых сегментов в s16le.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun gainShort_quietSamples_boosted5_saturated() {
|
||||
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
|
||||
val out = gainShort(samples, 5f)
|
||||
assertEquals(samples.size, out.size)
|
||||
assertEquals(5_000, out[0].toInt(), "1000 × 5")
|
||||
assertEquals(-5_000, out[1].toInt(), "-1000 × 5")
|
||||
assertEquals(32_767, out[2].toInt(), "20000 × 5 → насыщение")
|
||||
assertEquals(-32_768, out[3].toInt(), "-32768 × 5 → насыщение")
|
||||
assertEquals(0, out[4].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gainShort_gain1_returnsSameArray() {
|
||||
val samples = shortArrayOf(1, 2, 3)
|
||||
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downsample48To16_thirdLengthAndFIR() {
|
||||
// 9 сэмплов 48 кГц (3 фрейма по 3) → 3 сэмпла 16 кГц
|
||||
val frames = listOf(shortArrayOf(3, 6, 9), shortArrayOf(12, 15, 18), shortArrayOf(21, 24, 27))
|
||||
val out = downsample48To16(frames)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals(3, out[0].size, "длина = 1/3")
|
||||
// тройки (3,6,9) → 6; (12,15,18) → 15; (21,24,27) → 24
|
||||
assertEquals(6, out[0][0].toInt())
|
||||
assertEquals(15, out[0][1].toInt())
|
||||
assertEquals(24, out[0][2].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downsample48To16_shortSegment_keepsFloor() {
|
||||
// 5 сэмплов → 1 (5/3 = 1)
|
||||
val out = downsample48To16(listOf(shortArrayOf(1, 2, 3, 4, 5)))
|
||||
assertEquals(1, out[0].size)
|
||||
assertEquals(2, out[0][0].toInt(), "среднее (1,2,3)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortsToPcm16_concatenatesLittleEndian() {
|
||||
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
|
||||
val out = shortsToPcm16(frames)
|
||||
assertEquals(6, out.size, "3 сэмпла × 2 байта")
|
||||
assertEquals(1, out[0].toInt() and 0xff)
|
||||
assertEquals(0, out[1].toInt())
|
||||
assertEquals(2, out[2].toInt() and 0xff)
|
||||
assertEquals(0, out[3].toInt())
|
||||
assertEquals(3, out[4].toInt() and 0xff)
|
||||
assertEquals(0, out[5].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortsToPcm16_negativeSample_twoBytesComplement() {
|
||||
val out = shortsToPcm16(listOf(shortArrayOf(-1)))
|
||||
assertEquals(0xff, out[0].toInt() and 0xff)
|
||||
assertEquals(0xff, out[1].toInt() and 0xff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortsToPcm16_empty_returnsEmpty() {
|
||||
assertEquals(0, shortsToPcm16(emptyList()).size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SttModelProviderTest {
|
||||
|
||||
private fun newModelDir(): File {
|
||||
val dir = Files.createTempDirectory("stt-model-test").toFile()
|
||||
dir.deleteOnExit()
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun smallFiles() = listOf(
|
||||
ModelFile("a.onnx", 4L, "sha-a"),
|
||||
ModelFile("tokenizer/tok.json", 5L, "sha-b"),
|
||||
)
|
||||
|
||||
private fun write(modelDir: File, relPath: String, size: Int) {
|
||||
val f = File(modelDir, relPath)
|
||||
f.parentFile?.mkdirs()
|
||||
f.writeBytes(ByteArray(size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifestIsSane() {
|
||||
assertEquals(6, SttModel.FILES.size)
|
||||
assertEquals(6, SttModel.FILES.map { it.relPath }.toSet().size, "пути дублируются")
|
||||
SttModel.FILES.forEach { f ->
|
||||
assertTrue(f.sha256Hex.length == 64, "некорректный SHA-256: ${f.relPath}")
|
||||
assertTrue(f.sizeBytes > 0, "нулевой размер: ${f.relPath}")
|
||||
}
|
||||
// Иерархия 1:1 с filesDir/models телефона: 3 корневых .onnx + tokenizer/ (3 файла).
|
||||
assertTrue(SttModel.FILES.any { it.relPath == "conv_frontend.onnx" })
|
||||
assertTrue(SttModel.FILES.any { it.relPath == "tokenizer/merges.txt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDirNeedsAllFiles() {
|
||||
val dir = newModelDir()
|
||||
val files = smallFiles()
|
||||
val needed = whichFilesNeeded(dir, files)
|
||||
assertEquals(files, needed)
|
||||
assertTrue(!isModelComplete(dir, files))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completeDirNeedsNothing() {
|
||||
val dir = newModelDir()
|
||||
val files = smallFiles()
|
||||
write(dir, "a.onnx", 4)
|
||||
write(dir, "tokenizer/tok.json", 5)
|
||||
assertEquals(emptyList(), whichFilesNeeded(dir, files))
|
||||
assertTrue(isModelComplete(dir, files))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongSizeTreatedAsNeeded() {
|
||||
val dir = newModelDir()
|
||||
val files = smallFiles()
|
||||
write(dir, "a.onnx", 3) // короче ожидаемых 4
|
||||
write(dir, "tokenizer/tok.json", 5)
|
||||
val needed = whichFilesNeeded(dir, files)
|
||||
assertEquals(listOf("a.onnx"), needed.map { it.relPath })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelTotalBytesIsSum() {
|
||||
assertEquals(9L, modelTotalBytes(smallFiles()))
|
||||
assertTrue(
|
||||
modelTotalBytes() in 900_000_000..1_000_000_000,
|
||||
"сумма байт модели не в ожидаемом порядке: ${modelTotalBytes()}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sha256HexOfKnownInput() {
|
||||
val dir = newModelDir()
|
||||
val f = File(dir, "known.bin")
|
||||
f.writeBytes("abc".encodeToByteArray())
|
||||
assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", sha256Hex(f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Регрессия: размеры в WAV-заголовке должны быть little-endian — из-за big-endian
|
||||
* (RandomAccessFile.writeInt) плееры не открывали дампы stt_dump_*.wav:
|
||||
* для файла 188844 байта байты 4..8 были 00 02 e1 a4 вместо a4 e1 02 00.
|
||||
*/
|
||||
class WavWriterTest {
|
||||
|
||||
@Test
|
||||
fun close_3200Data_riffAndDataSizesLittleEndian() {
|
||||
val bytes = writeAndClose(3_200)
|
||||
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
|
||||
"RIFF size = 36 + data, little-endian (байты 4..8) — не big-endian из writeInt")
|
||||
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44),
|
||||
"data size = 3200, little-endian (байты 40..44)")
|
||||
assertEquals(1_600, leIntAt(bytes, 40) / 2,
|
||||
"nframes = data/2 (16 кГц s16le mono) — как считает python wave")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun close_noData_riffSize36DataZero() {
|
||||
val bytes = writeAndClose(0)
|
||||
assertContentEquals(leIntBytes(36), bytes.copyOfRange(4, 8), "пустой data: RIFF size = 36")
|
||||
assertEquals(0, leIntAt(bytes, 40), "пустой data: data size = 0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeTwice_sizesUnchanged() {
|
||||
val file = tmpFile()
|
||||
val w = WavWriter(file)
|
||||
w.write(ByteArray(3_200))
|
||||
w.close()
|
||||
w.close()
|
||||
val bytes = file.readBytes()
|
||||
assertEquals(44 + 3_200, bytes.size, "второй close ничего не дописывает в файл")
|
||||
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
|
||||
"двойной close не портит размеры (флаг closed)")
|
||||
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44))
|
||||
}
|
||||
|
||||
private fun writeAndClose(dataBytes: Int): ByteArray {
|
||||
val file = tmpFile()
|
||||
val w = WavWriter(file)
|
||||
if (dataBytes > 0) w.write(ByteArray(dataBytes))
|
||||
w.close()
|
||||
assertEquals(44L + dataBytes, file.length(), "файл = 44-байтный заголовок + data")
|
||||
return file.readBytes()
|
||||
}
|
||||
|
||||
private fun tmpFile(): File {
|
||||
val f = Files.createTempFile("wvwriter-test", ".wav").toFile()
|
||||
f.deleteOnExit()
|
||||
return f
|
||||
}
|
||||
|
||||
/** 4 байта little-endian — ожидаемый вид записи в WAV-заголовке. */
|
||||
private fun leIntBytes(v: Int): ByteArray =
|
||||
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array()
|
||||
|
||||
/** Прочитать 4 байта little-endian с позиции offset (то, как rидер получит int32). */
|
||||
private fun leIntAt(bytes: ByteArray, offset: Int): Int {
|
||||
fun b(i: Int) = bytes[i].toInt() and 0xff
|
||||
return b(offset) or (b(offset + 1) shl 8) or (b(offset + 2) shl 16) or (b(offset + 3) shl 24)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import java.util.Properties
|
||||
import java.io.File
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@@ -7,6 +8,20 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
|
||||
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
|
||||
val binomCertsDir: File = File(
|
||||
(project.findProperty("binom.keystore.dir") as? String)
|
||||
?: System.getenv("BINOM_KEYSTORE_DIR")
|
||||
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
|
||||
)
|
||||
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
|
||||
val binomCreds: Map<String, String> =
|
||||
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
|
||||
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
|
||||
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
|
||||
else emptyMap() })
|
||||
|
||||
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
|
||||
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
|
||||
val llmApiKey: String = run {
|
||||
@@ -47,9 +62,30 @@ android {
|
||||
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
|
||||
// Без кестора binomCreds пусто → release остаётся неподписанным.
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
|
||||
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
|
||||
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
|
||||
signingConfigs {
|
||||
create("binom") {
|
||||
storeFile = binomStoreFile
|
||||
storePassword = binomStorePass
|
||||
keyAlias = binomAlias
|
||||
keyPassword = binomKeyPass
|
||||
enableV1Signing = false
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
signingConfig = signingConfigs.getByName("binom")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +130,28 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(project(":lib-core"))
|
||||
|
||||
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
|
||||
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
|
||||
|
||||
// QR-код для паринга очков (pairCode) — рендер BitMatrix->Bitmap
|
||||
implementation("com.google.zxing:core:3.5.2")
|
||||
|
||||
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||
implementation(libs.koog.openai.client)
|
||||
implementation(libs.koog.http.client.okhttp)
|
||||
|
||||
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
||||
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU):
|
||||
// KMP-обёртка pw.binom.litert (универсальный контракт RAW-инференса + google-бэкенд)
|
||||
implementation(libs.litert.google)
|
||||
// универсальный контракт (LiteLlm/LiteRequest…) — импортируется напрямую
|
||||
implementation(libs.litert.api)
|
||||
|
||||
// Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), JitPack
|
||||
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
||||
|
||||
// Офлайн-распознавание Qwen3-ASR (asr-kmp, caffeine nexus) — Qwen3AsrStt
|
||||
implementation("pw.binom.asr:asr-qwen3-android:4")
|
||||
|
||||
// WS-сервер телефона (CIO — работает на Android)
|
||||
implementation("io.ktor:ktor-server-core:3.3.0")
|
||||
implementation("io.ktor:ktor-server-cio:3.3.0")
|
||||
@@ -108,6 +159,8 @@ dependencies {
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
// Тестовый стриминговый VAD (Silero на ONNX, JVM) — из mavenLocal
|
||||
testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT")
|
||||
testImplementation("io.ktor:ktor-server-test-host:3.3.0")
|
||||
testImplementation("io.ktor:ktor-client-websockets:3.3.0")
|
||||
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
|
||||
|
||||
@@ -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,19 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<!-- Bluetooth SPP-транспорт (включается флагом PhoneConfig.USE_BLUETOOTH):
|
||||
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
|
||||
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".PhoneApp"
|
||||
@@ -16,13 +28,28 @@
|
||||
android:allowBackup="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- NOTE 2026-08-23 (porfiry): точь-в-точь как у автора — ВНУТРИ <application>
|
||||
(AndroidManifest.xml автора, строки 59-61: три uses-native-library).
|
||||
Системные libOpenCL.so (Adreno) + libcdsprpc.so (Qualcomm-расчёты)
|
||||
видны из app-namespace; без них LiteRT-LM падает на
|
||||
"Can not find OpenCL library on this device". -->
|
||||
<uses-native-library android:name="libvndksupport.so" android:required="false"/>
|
||||
<uses-native-library android:name="libOpenCL.so" android:required="false"/>
|
||||
<uses-native-library android:name="libcdsprpc.so" android:required="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -20,34 +22,66 @@ import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */
|
||||
/** Решение одного sync-цикла: speed — желаемая скорость аудио; seekToMs не null → редкий жёсткий seek; playing не null → play/pause. */
|
||||
internal data class SyncDecision(
|
||||
val speed: Float = 1f,
|
||||
val seekToMs: Long? = null,
|
||||
val playing: Boolean? = null,
|
||||
)
|
||||
|
||||
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */
|
||||
internal const val SYNC_THRESHOLD_MS = 1000L
|
||||
/** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
|
||||
internal const val TRIGGER_BAND_MS = 150L
|
||||
|
||||
/**
|
||||
* Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f).
|
||||
* 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED].
|
||||
*/
|
||||
internal const val DRIFT_GAIN = 0.0002f
|
||||
|
||||
/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */
|
||||
internal const val MAX_DRIFT_SPEED = 0.10f
|
||||
|
||||
/**
|
||||
* Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo.
|
||||
* Поднят с 2000 до 5000: обычная буферная пауза вывода (0.5–1.5 с) при локальном файле не должна
|
||||
* форсировать жёсткий seek (он перебуферизирует ExoPlayer и даёт слышимую тишину + прыжок позиции);
|
||||
* умеренный рассинхрон закрывается плавной коррекцией скоростью, бесшовно.
|
||||
*/
|
||||
internal const val HARD_SEEK_THRESHOLD_MS = 5000L
|
||||
|
||||
/** EWMA-сглаживание измеренной латентности линка (доля старого значения). */
|
||||
internal const val LINK_LATENCY_ALPHA = 0.8
|
||||
/** Верхний предел измеренной латентности: выше — артефакт (обрыв/зависание), игнорируем. */
|
||||
internal const val LINK_LATENCY_MAX_MS = 2000.0
|
||||
|
||||
/**
|
||||
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
|
||||
* - |audio - video| > [thresholdMs] → перемотать на позицию очков;
|
||||
* - isPlaying очков отличается от состояния аудио → play/pause.
|
||||
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
|
||||
* - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем),
|
||||
* звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается
|
||||
* [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно;
|
||||
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
|
||||
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
|
||||
*/
|
||||
internal fun decideSync(
|
||||
audioPosition: Long,
|
||||
videoPosition: Long,
|
||||
audioPlaying: Boolean,
|
||||
videoPlaying: Boolean,
|
||||
thresholdMs: Long = SYNC_THRESHOLD_MS,
|
||||
): SyncDecision {
|
||||
val seekToMs = if (abs(audioPosition - videoPosition) > thresholdMs) videoPosition else null
|
||||
val diff = audioPosition - videoPosition
|
||||
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
|
||||
val speed = when {
|
||||
seekToMs != null -> 1f
|
||||
abs(diff) <= TRIGGER_BAND_MS -> 1f
|
||||
else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED)
|
||||
}
|
||||
val playing = when {
|
||||
videoPlaying == audioPlaying -> null
|
||||
videoPlaying -> true
|
||||
else -> false
|
||||
}
|
||||
return SyncDecision(seekToMs = seekToMs, playing = playing)
|
||||
return SyncDecision(speed = speed, seekToMs = seekToMs, playing = playing)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,15 +149,51 @@ class AudioSyncPlayer(private val context: Context) {
|
||||
private var player: ExoPlayer? = null
|
||||
private var ticker: Job? = null
|
||||
|
||||
/** Позиция очков из последнего sync (для догонки после неточного seekTo). */
|
||||
/** Слушатель буферизации ExoPlayer: логирует паузы вывода с длительностью (причина «немого» звука:
|
||||
* длительная буферизация = локальная/S3-дорога, короткая/абсолютное отсутствие = BT-стек). */
|
||||
private val bufferingListener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
bufferingStartMs = System.currentTimeMillis()
|
||||
log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)")
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
val bufferedMs = if (bufferingStartMs > 0L) System.currentTimeMillis() - bufferingStartMs else 0L
|
||||
bufferingStartMs = 0L
|
||||
log("audio", "буфер готов, вывод возобновлён${if (bufferedMs > 0L) " (буферизация ${bufferedMs} мс)" else ""}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Позиция очков из последнего sync. */
|
||||
private var lastVideoPositionMs = 0L
|
||||
/** IsPlaying очков из последнего sync. */
|
||||
private var lastVideoPlaying = false
|
||||
/** Сглаженная (EWMA) латентность линка очки→телефон (мс): запаздывание мастер-позиции. */
|
||||
private var linkLatencyMs = 0.0
|
||||
private var lastLatencyLoggedAt = 0L
|
||||
|
||||
/** Текущий источник для логов: «стриминг S3» или «локальный файл» — виден в каждой периодической
|
||||
* строке «audio sync:», чтобы любой фрагмент logcat отвечал на вопрос «стримлю или скачанное?». */
|
||||
private var sourceLabel = "нет"
|
||||
|
||||
/** Момент входа в STATE_BUFFERING (для лога длительности; 0 — не в буферизации). */
|
||||
private var bufferingStartMs = 0L
|
||||
|
||||
private val _positionMs = MutableStateFlow(0L)
|
||||
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
|
||||
val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
|
||||
|
||||
private val _diffMs = MutableStateFlow(0L)
|
||||
/** |звук − видео| из последнего sync-цикла (мс), для индикатора статус-панели очков. 0 — синхронизации не было. */
|
||||
val diffMs: StateFlow<Long> = _diffMs.asStateFlow()
|
||||
|
||||
/** Играет ли видео на очках (по последнему PlaybackPosition). Только с главного потока. */
|
||||
val videoPlaying: Boolean
|
||||
get() = lastVideoPlaying
|
||||
|
||||
/** Есть ли активный аудио-плеер (просмотр на очках). */
|
||||
val isActive: Boolean
|
||||
get() = player != null
|
||||
@@ -139,43 +209,62 @@ class AudioSyncPlayer(private val context: Context) {
|
||||
} else {
|
||||
Uri.fromFile(File(urlOrFile))
|
||||
}
|
||||
val p = player ?: ExoPlayer.Builder(context).build().also { player = it }
|
||||
val p = player ?: ExoPlayer.Builder(context).build().also {
|
||||
it.addListener(bufferingListener)
|
||||
player = it
|
||||
}
|
||||
p.setMediaItem(MediaItem.fromUri(uri))
|
||||
p.prepare()
|
||||
p.seekTo(startPositionMs.coerceAtLeast(0L))
|
||||
p.playWhenReady = true
|
||||
log("audio", "играю $urlOrFile с ${startPositionMs} мс")
|
||||
val isStream = urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://")
|
||||
sourceLabel = if (isStream) "стриминг S3" else "локальный файл"
|
||||
log("audio", "играю $sourceLabel: $urlOrFile с ${startPositionMs} мс")
|
||||
startTicker()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Коррекция по позиции очков: diff > 1000 мс → seek, play/pause по isPlaying.
|
||||
* Пишет ключевой лог для QA при каждом вызове.
|
||||
* Коррекция по позиции очков: за пределами гистерезис-зоны скорость плавно
|
||||
* подгоняется через setPlaybackParameters (без seekTo); жёсткий seek — только
|
||||
* при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f.
|
||||
* Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове.
|
||||
*/
|
||||
fun sync(positionMs: Long, isPlaying: Boolean) {
|
||||
fun sync(positionMs: Long, isPlaying: Boolean, sentTs: Long = 0L) {
|
||||
main.post {
|
||||
val p = player ?: return@post
|
||||
lastVideoPositionMs = positionMs
|
||||
lastVideoPlaying = isPlaying
|
||||
val audioPos = p.currentPosition
|
||||
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying)
|
||||
decision.seekToMs?.let {
|
||||
p.seekTo(it)
|
||||
lastVideoPositionMs = it
|
||||
// Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с),
|
||||
// второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с).
|
||||
main.postDelayed({
|
||||
val pp = player ?: return@postDelayed
|
||||
val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду
|
||||
if (abs(pp.currentPosition - target) > 500) {
|
||||
pp.seekTo(target)
|
||||
log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})")
|
||||
// Латентность линка очки→телефон: сколько мс прошло от отправки позиции на очках до обработки здесь.
|
||||
// Часы синхронизированы через mercury.syncTime(), так что это чистая транспортная задержка.
|
||||
if (sentTs > 0L) {
|
||||
val measured = (System.currentTimeMillis() - sentTs).toDouble()
|
||||
if (measured in 0.0..LINK_LATENCY_MAX_MS) {
|
||||
linkLatencyMs = linkLatencyMs * LINK_LATENCY_ALPHA + measured * (1 - LINK_LATENCY_ALPHA)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastLatencyLoggedAt > 5000) {
|
||||
lastLatencyLoggedAt = now
|
||||
log("audio sync:", "латентность линка: ${linkLatencyMs.toInt()} мс")
|
||||
}
|
||||
}, 1_000)
|
||||
}
|
||||
}
|
||||
// Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел).
|
||||
val videoPos = positionMs + linkLatencyMs.toLong()
|
||||
val audioPos = p.currentPosition
|
||||
_diffMs.value = abs(audioPos - videoPos)
|
||||
val decision = decideSync(audioPos, videoPos, p.isPlaying, isPlaying)
|
||||
when {
|
||||
decision.seekToMs != null -> {
|
||||
p.seekTo(decision.seekToMs)
|
||||
p.setPlaybackParameters(PlaybackParameters(1f))
|
||||
log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)} src=$sourceLabel)")
|
||||
}
|
||||
else -> {
|
||||
p.setPlaybackParameters(PlaybackParameters(decision.speed))
|
||||
log("audio sync:", "video=$videoPos audio=$audioPos diff=${abs(audioPos - videoPos)} speed=${decision.speed} src=$sourceLabel")
|
||||
}
|
||||
}
|
||||
decision.playing?.let { if (it) p.play() else p.pause() }
|
||||
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +287,10 @@ class AudioSyncPlayer(private val context: Context) {
|
||||
ticker = null
|
||||
val p = player
|
||||
player = null
|
||||
lastVideoPlaying = false
|
||||
sourceLabel = "нет"
|
||||
_positionMs.value = 0L
|
||||
_diffMs.value = 0L
|
||||
if (p != null) {
|
||||
p.playWhenReady = false
|
||||
p.stop()
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothServerSocket
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.core.log
|
||||
import pw.binom.viewmate.core.net.BtTransportConstants
|
||||
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Bluetooth-транспорт, сторона телефона («сервер») — TASK-transport.md п.3:
|
||||
* RFCOMM/SPP ([BtTransportConstants.SPP_UUID]), сообщения — строки
|
||||
* (JSON + перевод строки как разделитель), read-поток — на Dispatchers.IO,
|
||||
* запись — под синхронизацией на сокете. [connId] = MAC-адрес очков.
|
||||
* Работает флагом [pw.binom.viewmate.phone.PhoneConfig.USE_BLUETOOTH]
|
||||
* (дефолт — [WifiServerTransport]).
|
||||
* @param context — для runtime-проверки BT-пермишенов (Android 12+ / до 12).
|
||||
*/
|
||||
class BtServerTransport(
|
||||
private val context: Context? = null,
|
||||
) : GlassesServerTransport {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для сканирования; ADVERTISE — для GATT-сервера
|
||||
* очков, Mercury-управление),
|
||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||
*/
|
||||
fun runtimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}
|
||||
|
||||
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||
runtimePermissions().all {
|
||||
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
override val name = "bluetooth"
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
@Volatile
|
||||
private var serverSocket: BluetoothServerSocket? = null
|
||||
|
||||
private val sockets = ConcurrentHashMap<String, BluetoothSocket>()
|
||||
private val writers = ConcurrentHashMap<String, OutputStream>()
|
||||
private val readerJobs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
|
||||
private val readerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Бесконечно принимать SPP-соединения очков; без BT/пермишена — ретраи через 3с. */
|
||||
override suspend fun accept(
|
||||
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||
onConnected: (connId: String) -> Unit,
|
||||
onDisconnected: (connId: String) -> Unit,
|
||||
) {
|
||||
while (!closed) {
|
||||
if (serverSocket == null) {
|
||||
serverSocket = openServerSocket()
|
||||
if (serverSocket == null) {
|
||||
log("bt", "SPP-сервер не создан (BT выключен? нет пермишена?), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
log("bt", "SPP-сервер запущен: ${BtTransportConstants.SERVICE_NAME} (${BtTransportConstants.SPP_UUID})")
|
||||
}
|
||||
try {
|
||||
val s = withContext(Dispatchers.IO) { serverSocket!!.accept() } ?: break
|
||||
if (closed) {
|
||||
runCatching { s.close() }
|
||||
break
|
||||
}
|
||||
val remote: BluetoothDevice = s.remoteDevice
|
||||
val connId = remote.address
|
||||
sockets[connId] = s
|
||||
writers[connId] = s.outputStream
|
||||
log("bt", "очки подключились: $connId")
|
||||
onConnected(connId)
|
||||
readerJobs[connId] = readerScope.launch {
|
||||
readLines(s, connId, onMessage, onDisconnected)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("bt", "accept прерван (${e::class.simpleName}: ${e.message}), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||
runCatching { serverSocket?.close() }
|
||||
serverSocket = null
|
||||
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
cleanupConnections()
|
||||
readerScope.cancel()
|
||||
}
|
||||
|
||||
/** SPP-сервер (RFCOMM-канал SPP): без BT-радио/пермишена — null (лечь и ретраиться). */
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun openServerSocket(): BluetoothServerSocket? {
|
||||
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
|
||||
log("bt", "BT-радио не найдено — SPP недоступен")
|
||||
return null
|
||||
}
|
||||
if (!adapter.isEnabled) {
|
||||
log("bt", "Bluetooth выключен — включите в настройках Android")
|
||||
return null
|
||||
}
|
||||
val ctx = context
|
||||
if (ctx != null && !hasBtPermissions(ctx)) {
|
||||
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
adapter.listenUsingRfcommWithServiceRecord(
|
||||
BtTransportConstants.SERVICE_NAME,
|
||||
UUID.fromString(BtTransportConstants.SPP_UUID),
|
||||
)
|
||||
}.onFailure { log("bt", "не удалось создать SPP-сервер: ${it.message}") }.getOrNull()
|
||||
}
|
||||
|
||||
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
|
||||
private suspend fun readLines(
|
||||
s: BluetoothSocket,
|
||||
connId: String,
|
||||
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||
onDisconnected: (connId: String) -> Unit,
|
||||
) {
|
||||
val reader = BufferedReader(InputStreamReader(s.inputStream))
|
||||
try {
|
||||
while (!closed) {
|
||||
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
|
||||
if (line.isNotBlank()) onMessage(connId, line)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("bt", "read-поток $connId оборван: ${e.message}")
|
||||
} finally {
|
||||
runCatching { reader.close() }
|
||||
runCatching { s.close() }
|
||||
sockets.remove(connId)
|
||||
writers.remove(connId)
|
||||
readerJobs.remove(connId)
|
||||
}
|
||||
if (!closed) {
|
||||
log("bt", "очки отключились: $connId")
|
||||
onDisconnected(connId)
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON-строка в сокет [connId]: UTF-8 + разделитель, под синхронизацией на сокете. */
|
||||
override suspend fun send(connId: String, json: String) {
|
||||
val out = writers[connId] ?: return
|
||||
runCatching {
|
||||
synchronized(out) {
|
||||
out.write(json.toByteArray(Charsets.UTF_8))
|
||||
out.write(BtTransportConstants.NEWLINE.code)
|
||||
out.flush()
|
||||
}
|
||||
}.onFailure { log("bt", "запись на $connId не удалась: ${it.message}") }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
runCatching { serverSocket?.close() }
|
||||
serverSocket = null
|
||||
cleanupConnections()
|
||||
readerScope.cancel()
|
||||
}
|
||||
|
||||
private fun cleanupConnections() {
|
||||
readerJobs.values.forEach { it.cancel() }
|
||||
readerJobs.clear()
|
||||
sockets.values.forEach { runCatching { it.close() } }
|
||||
sockets.clear()
|
||||
writers.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
|
||||
/**
|
||||
* Отладочный HTTP-сервер в приложении (только для разработки).
|
||||
* Позволяет дёргать ассистента и переключать модель без UI:
|
||||
*
|
||||
* GET /status — выбор модели, готовность движка, размер файла
|
||||
* POST /chat {"text":"привет"} — отправить фразу в ассистента (та же очередь, что UI)
|
||||
* POST /model {"choice":"local"} — переключить модель (local|remote), персист
|
||||
*
|
||||
* Запускается на 127.0.0.1:8787. Доступ: `adb forward tcp:8787 tcp:8787`.
|
||||
*/
|
||||
object DebugHttpServer {
|
||||
|
||||
private const val PORT = 8787
|
||||
private const val TAG = "httpd"
|
||||
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
fun start(app: PhoneApp) {
|
||||
if (running) return
|
||||
running = true
|
||||
Thread {
|
||||
try {
|
||||
val server = ServerSocket(PORT, 8, InetAddress.getByName("127.0.0.1"))
|
||||
log(TAG, "debug-http на 127.0.0.1:$PORT (adb forward tcp:8787 tcp:8787)")
|
||||
while (running) {
|
||||
try {
|
||||
val socket = server.accept()
|
||||
Thread {
|
||||
try {
|
||||
handle(socket, app)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, "обработка запроса: ${e.message}")
|
||||
} finally {
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}.apply { isDaemon = true }.start()
|
||||
} catch (e: Exception) {
|
||||
if (running) log(TAG, "accept: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log(TAG, "не удалось запустить: ${e.message}")
|
||||
}
|
||||
}.apply { isDaemon = true; name = "viewmate-httpd" }.start()
|
||||
}
|
||||
|
||||
private fun handle(socket: java.net.Socket, app: PhoneApp) {
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val requestLine = reader.readLine() ?: return
|
||||
val parts = requestLine.split(" ")
|
||||
if (parts.size < 2) return
|
||||
val method = parts[0]
|
||||
val rawPath = parts[1]
|
||||
val path = rawPath.substringBefore("?")
|
||||
val queryParams = rawPath.substringAfter("?", "").split("&")
|
||||
.filter { it.isNotEmpty() }
|
||||
.associate { it.substringBefore("=") to it.substringAfter("=") }
|
||||
|
||||
// Тело (до Content-Length)
|
||||
var contentLength = 0
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
if (line.isEmpty()) break
|
||||
if (line.startsWith("Content-Length:", true)) {
|
||||
contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
val body = if (contentLength > 0) {
|
||||
val buf = CharArray(contentLength)
|
||||
var read = 0
|
||||
while (read < contentLength) {
|
||||
val n = reader.read(buf, read, contentLength - read)
|
||||
if (n < 0) break
|
||||
read += n
|
||||
}
|
||||
String(buf, 0, read)
|
||||
} else ""
|
||||
|
||||
val response: String
|
||||
val code: Int
|
||||
when {
|
||||
method == "GET" && path.startsWith("/status") -> {
|
||||
val choice = app.llmChoice.value
|
||||
val local = app.localLlmClient()
|
||||
val file = app.localModelFile()
|
||||
response = """{"choice":"${choice.name.lowercase()}","local":{${localStatus(local, file)}}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/chat") -> {
|
||||
val text = jsonField(body, "text") ?: ""
|
||||
if (text.isBlank()) {
|
||||
response = """{"error":"пустой text"}"""
|
||||
code = 400
|
||||
} else {
|
||||
app.sendToAssistant(text)
|
||||
response = """{"ok":true,"queued":"${text.take(60)}"}"""
|
||||
code = 200
|
||||
}
|
||||
}
|
||||
method == "POST" && path.startsWith("/model") -> {
|
||||
val choice = jsonField(body, "choice")
|
||||
if (choice == "local" || choice == "remote") {
|
||||
app.setLlmModelChoice(if (choice == "local") LlmModelChoice.LOCAL else LlmModelChoice.REMOTE)
|
||||
response = """{"ok":true,"choice":"$choice"}"""
|
||||
code = 200
|
||||
} else {
|
||||
response = """{"error":"choice должен быть local|remote"}"""
|
||||
code = 400
|
||||
}
|
||||
}
|
||||
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
|
||||
method == "GET" && path.startsWith("/mercury/status") -> {
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"},"pairing":${s.pairCode != null},"pairCode":${s.pairCode?.let { "\"$it\"" } ?: "null"},"realAddress":${s.realAddress?.let { "\"$it\"" } ?: "null"}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "GET" && path.startsWith("/mercury/bonded") -> {
|
||||
val bonds = MercuryBridge.bondedAddresses()
|
||||
val items = bonds.joinToString(",") { (addr, name) ->
|
||||
"""{"address":"$addr","name":${name?.let { jsonEscape(it) } ?: "null"}}"""
|
||||
}
|
||||
response = """{"count":${bonds.size},"bonds":[$items]}"""
|
||||
code = 200
|
||||
}
|
||||
method == "GET" && path.startsWith("/mercury/discover") -> {
|
||||
val seconds = queryParams["time"]?.toIntOrNull() ?: 5
|
||||
val found = MercuryBridge.discoverGlasses(seconds * 1_000L)
|
||||
val items = found.joinToString(",") { (name, addr) ->
|
||||
"""{"name":${name?.let { jsonEscape(it) } ?: "null"},"address":"$addr"}"""
|
||||
}
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"count":${found.size},"found":[$items],"connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/pair/stop") -> {
|
||||
MercuryBridge.cancelPairing()
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":true,"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/pair/classic") -> {
|
||||
val address = queryParams["address"]
|
||||
if (address.isNullOrBlank()) {
|
||||
response = """{"error":"нет параметра address"}"""
|
||||
code = 400
|
||||
} else {
|
||||
val ok = MercuryBridge.pairClassic(address)
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":$ok,"address":"$address","connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/pair") -> {
|
||||
val pairCode = MercuryBridge.startPairing()
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":${pairCode != null},"pairCode":${pairCode?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/unpair") -> {
|
||||
val address = queryParams["address"]
|
||||
if (address.isNullOrBlank()) {
|
||||
response = """{"error":"нет параметра address"}"""
|
||||
code = 400
|
||||
} else {
|
||||
val ok = MercuryBridge.unpair(address)
|
||||
response = """{"ok":$ok,"address":"$address"}"""
|
||||
code = 200
|
||||
}
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/connect") -> {
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
|
||||
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/disconnect") -> {
|
||||
kotlinx.coroutines.runBlocking { MercuryBridge.disconnect() }
|
||||
response = """{"ok":true}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/screen/off") -> {
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOff() }
|
||||
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/screen/on") -> {
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOn() }
|
||||
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/autolock") -> {
|
||||
val time = queryParams["time"]?.toIntOrNull() ?: 10
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.autoLockTime(time) }
|
||||
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/wifi") -> {
|
||||
val enable = "true".equals(queryParams["enable"], ignoreCase = true)
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiToggle(enable) }
|
||||
response = """{"ok":$ok,"enable":$enable,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/wifiscan") -> {
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiScan() }
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":$ok,"networks":${s.wifiNetworks.size},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
else -> {
|
||||
response = """{"error":"нет такого пути: $method $path"}"""
|
||||
code = 404
|
||||
}
|
||||
}
|
||||
|
||||
val out = socket.getOutputStream()
|
||||
val payload = response.toByteArray(Charsets.UTF_8)
|
||||
out.write(("HTTP/1.1 $code ${reason(code)}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8))
|
||||
out.write(payload)
|
||||
out.flush()
|
||||
}
|
||||
|
||||
private fun localStatus(local: LocalLlmClient?, file: java.io.File): String {
|
||||
if (local == null) return """"ready":false,"fileExists":${file.exists()},"fileBytes":${file.length()}"""
|
||||
return """"ready":true,"modelDownloaded":${local.modelDownloaded()},"fileExists":${file.exists()},"fileBytes":${file.length()}"""
|
||||
}
|
||||
|
||||
private fun jsonField(body: String, key: String): String? {
|
||||
// {"text":"..."} — примитивный парсер, без кавычек внутри значения
|
||||
val regex = Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"")
|
||||
return regex.find(body)?.groupValues?.get(1)
|
||||
}
|
||||
|
||||
private fun jsonEscape(s: String): String =
|
||||
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
|
||||
|
||||
private fun reason(code: Int): String = when (code) {
|
||||
200 -> "OK"
|
||||
400 -> "Bad Request"
|
||||
404 -> "Not Found"
|
||||
else -> "Error"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.log.LogEntry
|
||||
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||
import pw.binom.viewmate.core.phone.GlassesSender
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
@@ -27,10 +23,12 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Gesture
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.LogBatchMsg
|
||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.core.protocol.SttText
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
@@ -41,14 +39,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
||||
|
||||
/**
|
||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
||||
* DownloadProgress → карта скачивания на очки).
|
||||
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
|
||||
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
|
||||
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
|
||||
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
|
||||
*/
|
||||
class GlassesHub(
|
||||
private val state: PhoneState,
|
||||
/** Выбранный транспорт (дефолт — [WifiServerTransport]). */
|
||||
private val transport: GlassesServerTransport,
|
||||
) {
|
||||
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
|
||||
/** Сессии: connId → Unit. */
|
||||
private val sessions = ConcurrentHashMap<String, Unit>()
|
||||
|
||||
private val _connected = MutableStateFlow(0)
|
||||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||||
@@ -96,46 +98,58 @@ class GlassesHub(
|
||||
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
|
||||
* рестарта телефона (см. PhoneApp.autostartAudio).
|
||||
*/
|
||||
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null
|
||||
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long, durationMs: Long) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
|
||||
* (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
|
||||
*/
|
||||
@Volatile
|
||||
var onLogBatch: ((List<LogEntry>) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
||||
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s
|
||||
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
|
||||
* рассылаются через [broadcast].
|
||||
*/
|
||||
@Volatile
|
||||
var stt: SttStreamer? = null
|
||||
|
||||
/**
|
||||
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
||||
* на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
|
||||
* отсутствуют — только полный текст (по клику или по тишине 30 с).
|
||||
*/
|
||||
@Volatile
|
||||
var onStopFullText: ((String) -> Unit)? = null
|
||||
|
||||
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
||||
@Volatile
|
||||
var sttFactory: (() -> SttStreamer?)? = null
|
||||
|
||||
fun add(session: DefaultWebSocketServerSession) {
|
||||
sessions[session] = Unit
|
||||
fun add(connId: String) {
|
||||
sessions[connId] = Unit
|
||||
_connected.value = sessions.size
|
||||
log("hub", "очки подключились, всего: ${sessions.size}")
|
||||
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
|
||||
}
|
||||
|
||||
fun remove(session: DefaultWebSocketServerSession) {
|
||||
sessions.remove(session)
|
||||
fun remove(connId: String) {
|
||||
sessions.remove(connId)
|
||||
_connected.value = sessions.size
|
||||
log("hub", "очки отключились, всего: ${sessions.size}")
|
||||
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
|
||||
}
|
||||
|
||||
/** Отправить сообщение всем подключённым очкам. */
|
||||
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
|
||||
suspend fun broadcast(msg: HostToGlasses) {
|
||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||
for (session in sessions.keys) {
|
||||
try {
|
||||
session.send(text)
|
||||
} catch (e: Exception) {
|
||||
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
|
||||
}
|
||||
for (connId in sessions.keys) {
|
||||
runCatching { transport.send(connId, text) }
|
||||
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Обработать входящее текстовое сообщение от очков. */
|
||||
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
|
||||
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
|
||||
suspend fun handle(connId: String, text: String) {
|
||||
val msg = try {
|
||||
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
||||
} catch (e: Exception) {
|
||||
@@ -144,8 +158,8 @@ class GlassesHub(
|
||||
}
|
||||
when (msg) {
|
||||
is Hello -> {
|
||||
log("glasses", "hello (app=${msg.appVersion}) → welcome")
|
||||
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||||
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
|
||||
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||||
}
|
||||
|
||||
is Gesture -> {
|
||||
@@ -182,13 +196,18 @@ class GlassesHub(
|
||||
this.state.playing = msg.playing
|
||||
val state = if (msg.playing) "играет" else "пауза"
|
||||
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}")
|
||||
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex)
|
||||
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs, msg.durationMs)
|
||||
}
|
||||
|
||||
is GlassesOff -> {
|
||||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||||
this.state.playing = false
|
||||
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
|
||||
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L, -1L)
|
||||
}
|
||||
|
||||
is LogBatchMsg -> {
|
||||
log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
|
||||
onLogBatch?.invoke(msg.entries)
|
||||
}
|
||||
|
||||
is SttAudio -> {
|
||||
@@ -199,80 +218,89 @@ class GlassesHub(
|
||||
is StopStt -> {
|
||||
if (msg.cancel) {
|
||||
log("stt", "распознавание отменено (очки)")
|
||||
send(session, SttCancel())
|
||||
send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
|
||||
stt?.reset()
|
||||
} else {
|
||||
val full = stt?.finish() ?: ""
|
||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||
send(session, SttDone(full))
|
||||
stt?.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
|
||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||
session.send(text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
|
||||
fun Application.glassesServerModule(hub: GlassesHub) {
|
||||
install(WebSockets) {
|
||||
pingPeriodMillis = 5_000
|
||||
timeoutMillis = 5_000
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
routing {
|
||||
webSocket(GLASSES_WS_PATH) {
|
||||
hub.add(this)
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
hub.handle(this, text)
|
||||
if (full.isNotBlank()) {
|
||||
send(connId, SttDone(full))
|
||||
val phrase = full.trim()
|
||||
log("stt", "фраза в LLM (по клику): $phrase")
|
||||
onStopFullText?.invoke(phrase)
|
||||
} else {
|
||||
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
|
||||
log("stt", "фразы не было — очки в «слушаю»")
|
||||
broadcast(SttCancel(reason = SttCancel.REASON_USER))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("server", "WS-ошибка: ${e.message}")
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
hub.remove(this)
|
||||
}
|
||||
|
||||
// Новый путь: очки распознали речь локально и прислали готовый текст —
|
||||
// в LLM тем же каналом, что и StopStt(cancel=false). Old SttAudio/StopStt
|
||||
// остаются фолбэком для старых очей.
|
||||
is SttText -> {
|
||||
val full = msg.text.trim()
|
||||
if (full.isNotBlank()) {
|
||||
log("stt", "STT-текст от очков (локально): $full → LLM")
|
||||
onStopFullText?.invoke(full)
|
||||
} else {
|
||||
log("stt", "STT-текст от очков пуст — ignore")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(connId: String, msg: HostToGlasses) {
|
||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||
runCatching { transport.send(connId, text) }
|
||||
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
|
||||
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
|
||||
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
|
||||
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
|
||||
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
|
||||
* (SPP). Поведение по умолчанию (WiFi) не меняется.
|
||||
*/
|
||||
class GlassesServer(
|
||||
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||
private val state: PhoneState = PhoneState(),
|
||||
/** `false` (дефолт) — WiFi; `true` — Bluetooth SPP. */
|
||||
useBluetooth: Boolean = false,
|
||||
/** Context для runtime-проверки BT-пермишенов (нужен только в [BtServerTransport]). */
|
||||
private val btContext: Context? = null,
|
||||
) {
|
||||
val hub = GlassesHub(state)
|
||||
/** Активный транспорт: [WifiServerTransport] (дефолт) или [BtServerTransport]. */
|
||||
val transport: GlassesServerTransport =
|
||||
if (useBluetooth) BtServerTransport(btContext) else WifiServerTransport(port)
|
||||
|
||||
val hub = GlassesHub(state, transport)
|
||||
|
||||
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
|
||||
val sender: GlassesSender = object : GlassesSender {
|
||||
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
|
||||
}
|
||||
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var acceptJob: Job? = null
|
||||
|
||||
/** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */
|
||||
fun start() {
|
||||
if (server != null) return
|
||||
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
||||
glassesServerModule(hub)
|
||||
if (acceptJob != null) return
|
||||
acceptJob = scope.launch {
|
||||
transport.accept(
|
||||
onMessage = { connId, json -> hub.handle(connId, json) },
|
||||
onConnected = { connId -> hub.add(connId) },
|
||||
onDisconnected = { connId -> hub.remove(connId) },
|
||||
)
|
||||
}
|
||||
server!!.start(wait = false)
|
||||
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
server?.stop(1_000, 3_000)
|
||||
server = null
|
||||
transport.close()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
/** Единый лог телефона: простой println (работает и в unit-тестах). */
|
||||
fun log(tag: String, message: String) {
|
||||
println("[view-mate] $tag $message")
|
||||
import android.util.Log
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог телефона.
|
||||
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
|
||||
* (android.util.Log недоступен — метод без @JvmStatic фолбэктнет в println),
|
||||
* чтобы не падать.
|
||||
*
|
||||
* Плюс лог-коллектор: пачки (свои + пришедшие от очков) уходят на сервер
|
||||
* (HttpLogSink); при сбое — прихраняются в споуле и ретраятся позже.
|
||||
*/
|
||||
@Volatile
|
||||
private var logCollector: BatchingLogCollector? = null
|
||||
|
||||
/** Подключить коллектор (PhoneApp.onCreate). */
|
||||
fun attachLogCollector(collector: BatchingLogCollector) {
|
||||
logCollector = collector
|
||||
}
|
||||
|
||||
fun log(tag: String, message: String) {
|
||||
val full = "[view-mate] $tag $message"
|
||||
runCatching {
|
||||
Log.d("view-mate-$tag", full)
|
||||
}.onFailure {
|
||||
println(full)
|
||||
}
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.content.ContextCompat
|
||||
import pw.binom.viewmate.phone.stt.SttDebug
|
||||
import pw.binom.viewmate.phone.agent.LlmDebug
|
||||
import pw.binom.viewmate.phone.ui.ViewMateApp
|
||||
@@ -15,6 +19,8 @@ import java.io.File
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestBtPermissions()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
setContent {
|
||||
ViewMateApp()
|
||||
}
|
||||
@@ -26,6 +32,23 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-запрос Bluetooth-пермишенов (TASK-transport.md п.5): только при
|
||||
* вкл. SPP ([PhoneConfig.USE_BLUETOOTH]). На 31+ — BLUETOOTH_CONNECT/SCAN,
|
||||
* на ≤30 — BLUETOOTH/BLUETOOTH_ADMIN. Если уже выданы — молча пропускаем.
|
||||
*/
|
||||
private fun requestBtPermissions() {
|
||||
if (!PhoneConfig.USE_BLUETOOTH) return
|
||||
val needed = BtServerTransport.runtimePermissions()
|
||||
val missing = needed.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isNotEmpty()) {
|
||||
requestPermissions(missing.toTypedArray(), 1009)
|
||||
log("bt", "запрошены Bluetooth-пермишены (SPP): ${missing.joinToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
|
||||
private fun runLlmDebug() {
|
||||
showOverlay("[llm] вызываю llm.binom.pw...")
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.mercury.RayNeoMercury
|
||||
import pw.binom.mercury.model.MyWifiInfo
|
||||
import pw.binom.mercury.pair.PairManager
|
||||
|
||||
/**
|
||||
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
|
||||
*
|
||||
* Минимум для полевого теста: скан → pair → connect → погасить/включить экран
|
||||
* (deepSuspend). События либы логируются и частично проксируются в [status].
|
||||
*
|
||||
* Кнопки экрана — только проверка транспорта: если очки принимают команду,
|
||||
* протокол жив, дальше можно вешать остальные команды (яркость, WiFi, магазин).
|
||||
*/
|
||||
object MercuryBridge {
|
||||
|
||||
private const val TAG = "MercuryBridge"
|
||||
|
||||
private var mercury: RayNeoMercury? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
data class UiState(
|
||||
val connected: Boolean = false,
|
||||
val glassesName: String? = null,
|
||||
val statusText: String = "не подключено",
|
||||
val lastError: String? = null,
|
||||
val brightness: Int = 0,
|
||||
val automaticBrightness: Boolean = false,
|
||||
val wifiEnabled: Boolean = false,
|
||||
val wifiSsid: String? = null,
|
||||
val wifiNetworks: List<MyWifiInfo> = emptyList(),
|
||||
/** Активный pairCode для QR-паринга (не null, пока очки не подключились). */
|
||||
val pairCode: String? = null,
|
||||
/** Реальный классический BT-адрес очков, полученный в pre-bond окне (null до паринга). */
|
||||
val realAddress: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
private var currentGlasses: DiscoveredGlasses? = null
|
||||
|
||||
/** Инициализация (вызывать из Application). Стартует GATT-сервер: очки сами нас найдут. */
|
||||
fun init(context: Context) {
|
||||
if (mercury != null) return
|
||||
mercury = RayNeoMercury(context.applicationContext, scope)
|
||||
scope.launch {
|
||||
mercury!!.events.collect { event ->
|
||||
when (event) {
|
||||
is RayNeoMercury.Event.Connected -> {
|
||||
_state.value = _state.value.copy(connected = true, pairCode = null)
|
||||
setStatus("подключено (BLE)")
|
||||
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
|
||||
// ползунок яркости и переключатель WiFi показывали реальные значения.
|
||||
scope.launch { mercury?.pullGeneralStatus() }
|
||||
// Синхронизируем часы очков с телефоном: до NTP очки стартуют с
|
||||
// нулевой меткой (~1970), и логи получают «старые» таймстемпы,
|
||||
// из-за чего Loki отвергает весь batch (HTTP 400) и спул клинит.
|
||||
scope.launch { mercury?.syncTime() }
|
||||
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
|
||||
// при гашении экрана очки рвут BLE-связь через ~3 секунды.
|
||||
// Повторяем, пока очки не будут готовы (они переподключаются
|
||||
// в первые секунды и первое сообщение могут потерять).
|
||||
scope.launch {
|
||||
for (i in 0 until 30) {
|
||||
if (!_state.value.connected) break
|
||||
val ok = mercury?.enableHeartbeat() ?: false
|
||||
if (i % 3 == 0) log("hb", "enable_heartbeat #$i ok=$ok")
|
||||
delay(3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
is RayNeoMercury.Event.Disconnected -> {
|
||||
_state.value = _state.value.copy(connected = false)
|
||||
setStatus("отключено: ${event.cause}")
|
||||
}
|
||||
is RayNeoMercury.Event.JsonMessage -> {
|
||||
log("json", "${event.uri} ${event.json.take(200)}")
|
||||
// Очки шлют ble.request.* и ждут ble.response.* с тем же id.
|
||||
// Без ответа они рвут соединение — отвечаем эхом cmd/payload.
|
||||
val uri = event.uri
|
||||
val id = event.id
|
||||
if (uri != null && id != null && uri.startsWith("ble.request.") &&
|
||||
uri != "ble.request.general.status" && uri != "ble.request.general.settings"
|
||||
) {
|
||||
val respUri = uri.replace("ble.request.", "ble.response.")
|
||||
val payload = parsePayload(event.json)
|
||||
scope.launch {
|
||||
mercury?.send(pw.binom.mercury.model.PayloadMessage(respUri, id, payload))
|
||||
log("resp", "answered $uri -> $respUri")
|
||||
}
|
||||
}
|
||||
}
|
||||
is RayNeoMercury.Event.StreamPacket -> log("stream", "packet seq=${event.seq} type=${event.type} ${event.payload.size}B")
|
||||
is RayNeoMercury.Event.AppInstallStatus -> setStatus("app: ${event.pkg} status=${event.status} err=${event.errCode}")
|
||||
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
|
||||
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
|
||||
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> {
|
||||
val s = event.status
|
||||
_state.value = _state.value.copy(
|
||||
brightness = s.brightness,
|
||||
automaticBrightness = s.automaticBrightness,
|
||||
wifiEnabled = s.wifiEnabled,
|
||||
wifiSsid = s.wifiInfo?.ssid,
|
||||
)
|
||||
log("status", "brightness=${s.brightness} autoBrightness=${s.automaticBrightness} deepSuspend=${s.deepSuspend} wifi=${s.wifiEnabled} ssid=${s.wifiInfo?.ssid}")
|
||||
}
|
||||
is RayNeoMercury.Event.WifiScanResult -> {
|
||||
_state.value = _state.value.copy(wifiNetworks = event.networks)
|
||||
log("wifi", "сетей: ${event.networks.size}")
|
||||
}
|
||||
is RayNeoMercury.Event.RealBluetoothAddress -> {
|
||||
// Pre-bond handshake: очки сообщили свой реальный классический
|
||||
// BT-адрес (он же ожидает QR-pairCode). Создаём OS-level bond —
|
||||
// это и есть «паринг» (в стоке: PairStateMachine → AutoClassic → createBond).
|
||||
log("pair", "real bt address: ${event.address} name=${event.name} type=${event.type}")
|
||||
_state.value = _state.value.copy(realAddress = event.address)
|
||||
setStatus("получен адрес ${event.address}, настраиваю связь (bond)…")
|
||||
scope.launch {
|
||||
val m = mercury
|
||||
val res = if (m != null) m.pairByRealAddress(event.address)
|
||||
else PairManager.PairingResult.Failed("нет связи с библиотекой")
|
||||
when (res) {
|
||||
is PairManager.PairingResult.Success -> {
|
||||
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
|
||||
setStatus("спаривание успешно: ${event.address}, подключаюсь…")
|
||||
}
|
||||
is PairManager.PairingResult.Failed -> {
|
||||
setStatus("спаривание не удалось: ${res.reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val started = mercury!!.startServer()
|
||||
log("server", if (started) "GATT-сервер + реклама запущены" else "GATT-сервер НЕ стартовал")
|
||||
setStatus(if (started) "рекламирую сервис… жду очки" else "ошибка старта GATT-сервера")
|
||||
}
|
||||
|
||||
/** Прав на BLE нет — вернуть false; хост-приложение запрашивает само. */
|
||||
fun hasBlePermissions(context: Context): Boolean {
|
||||
val m = mercury ?: return false
|
||||
return m.hasPermissions() &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключение: гарантирует, что GATT-сервер рекламируется, и ждёт, пока
|
||||
* очки сами подключатся к нам (они действуют как GATT-клиент). Если очки
|
||||
* всё же рекламируют — дополнительно пробуем скан-путь (клиент).
|
||||
*/
|
||||
suspend fun connectFirst(timeoutMillis: Long = 25_000): Boolean {
|
||||
val m = mercury ?: return false
|
||||
if (!m.isServerRunning && !m.startServer()) {
|
||||
setError("не удалось запустить GATT-сервер")
|
||||
return false
|
||||
}
|
||||
setStatus("рекламирую сервис… жду очки")
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (m.isAnyConnected) {
|
||||
_state.value = _state.value.copy(connected = true)
|
||||
setStatus("подключено (BLE)")
|
||||
return true
|
||||
}
|
||||
// На случай, если очки рекламируются сами — клиентский путь.
|
||||
val glasses = m.scanOnce(3000)
|
||||
if (glasses != null) {
|
||||
setStatus("найдены: ${glasses.name ?: glasses.address}, подключаюсь…")
|
||||
currentGlasses = glasses
|
||||
if (m.connect(glasses, 8000)) {
|
||||
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
|
||||
setStatus("подключено (клиент)")
|
||||
return true
|
||||
}
|
||||
// Без bond очки не отдают GATT-сервис — спариваем и пробуем ещё раз.
|
||||
setStatus("connect не удался, спариваю…")
|
||||
when (val res = m.pair(glasses)) {
|
||||
is PairManager.PairingResult.Success -> {
|
||||
if (m.connect(glasses, 8000)) {
|
||||
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
|
||||
setStatus("подключено (клиент, после pair)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
is PairManager.PairingResult.Failed -> {
|
||||
setStatus("спаривание не удалось: ${res.reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setError("очки не подключились за ${timeoutMillis / 1000} с")
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
mercury?.disconnect()
|
||||
_state.value = _state.value.copy(connected = false)
|
||||
setStatus("отключено")
|
||||
}
|
||||
|
||||
/** Погасить экран очков (deepSuspend=1). */
|
||||
suspend fun screenOff(): Boolean {
|
||||
val ok = mercury?.setDeepSuspend(true) ?: false
|
||||
setStatus(if (ok) "команда «погасить экран» отправлена" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Включить экран очков (deepSuspend=0). */
|
||||
suspend fun screenOn(): Boolean {
|
||||
val ok = mercury?.setDeepSuspend(false) ?: false
|
||||
setStatus(if (ok) "команда «включить экран» отправлена" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Автоблокировка экрана через N секунд; -1 = никогда. */
|
||||
suspend fun autoLockTime(time: Int): Boolean {
|
||||
val ok = mercury?.setAutoLockTime(time) ?: false
|
||||
setStatus(if (ok) "auto_lock_time=$time отправлен" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Яркость экрана очков (0..100). Авто-яркость принудительно выключается — иначе очки игнорируют ручное значение. */
|
||||
suspend fun setBrightness(value: Int): Boolean {
|
||||
val v = value.coerceIn(0, 100)
|
||||
// При automaticBrightness=true очки отбрасывают brightness, поэтому выключаем авто заранее.
|
||||
mercury?.setAutoBrightness(false)
|
||||
val ok = mercury?.setBrightness(v) ?: false
|
||||
if (ok) _state.value = _state.value.copy(brightness = v, automaticBrightness = false)
|
||||
setStatus(if (ok) "яркость → $v%" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Авто-яркость очков. */
|
||||
suspend fun setAutoBrightness(enable: Boolean): Boolean {
|
||||
val ok = mercury?.setAutoBrightness(enable) ?: false
|
||||
if (ok) _state.value = _state.value.copy(automaticBrightness = enable)
|
||||
setStatus(if (ok) "авто-яркость → $enable" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Вкл/выкл WiFi-радио очков. */
|
||||
suspend fun wifiToggle(enable: Boolean): Boolean {
|
||||
val ok = mercury?.wifiToggle(enable) ?: false
|
||||
if (ok) _state.value = _state.value.copy(wifiEnabled = enable)
|
||||
setStatus(if (ok) "WiFi → ${if (enable) "вкл" else "выкл"}" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Подключить очки к WiFi-сети (пароль null, если открытая). */
|
||||
suspend fun wifiConnect(ssid: String, password: String?): Boolean {
|
||||
val ok = mercury?.wifiConnect(ssid, password) ?: false
|
||||
setStatus(if (ok) "WiFi: подключение к «$ssid»…" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Забыть сохранённую WiFi-сеть на очках. */
|
||||
suspend fun wifiRemove(ssid: String): Boolean {
|
||||
val ok = mercury?.wifiRemove(ssid) ?: false
|
||||
setStatus(if (ok) "WiFi: забыть «$ssid»" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Сканировать доступные WiFi-сети (результат → state.wifiNetworks). */
|
||||
suspend fun wifiScan(): Boolean {
|
||||
val ok = mercury?.wifiScan() ?: false
|
||||
setStatus(if (ok) "WiFi: сканирование…" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Запросить актуальный статус очков (яркость/WiFi/батарея). */
|
||||
suspend fun pullStatus(): Boolean {
|
||||
val ok = mercury?.pullGeneralStatus() ?: false
|
||||
if (!ok) setStatus("ошибка запроса статуса (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* Старт паринга: перезапускает GATT-сервер с pairCode в рекламе и отдаёт код
|
||||
* для QR. Пользователь показывает QR экрану очков — они его сканируют,
|
||||
* подключаются в pre-bond окне и шлют свой реальный адрес
|
||||
* (Event.RealBluetoothAddress), по которому мы делаем createBond.
|
||||
*/
|
||||
fun startPairing(): String? {
|
||||
val m = mercury ?: return null
|
||||
if (m.isAnyConnected) {
|
||||
setStatus("очки уже подключены — паринг не нужен")
|
||||
return null
|
||||
}
|
||||
val code = m.startPairing()
|
||||
_state.value = _state.value.copy(pairCode = code)
|
||||
setStatus("паринг: покажите QR экрану очков (код $code)")
|
||||
return code
|
||||
}
|
||||
|
||||
/** Отмена активного QR-паринга (прячет QR, перезапускает сервер в обычном режиме без pairCode). */
|
||||
fun cancelPairing() {
|
||||
_state.value = _state.value.copy(pairCode = null)
|
||||
mercury?.stopPairing()
|
||||
setStatus("паринг отменён")
|
||||
}
|
||||
|
||||
/**
|
||||
* Забывает очки (removeBond). Применяется, когда очки на своей стороне
|
||||
* потеряли бонд, а телефон держит «старый» бонд — рассинхрон блокирует
|
||||
* нормальное повторное паринг.
|
||||
*/
|
||||
fun unpair(address: String): Boolean {
|
||||
val m = mercury ?: return false
|
||||
return kotlinx.coroutines.runBlocking { m.unpairAddress(address) }
|
||||
}
|
||||
|
||||
/** Текущие классические BT-бонды телефона (адрес → имя). Debug-помощник. */
|
||||
fun bondedAddresses(): List<Pair<String, String?>> =
|
||||
mercury?.bondedAddresses() ?: emptyList()
|
||||
|
||||
/**
|
||||
* Классический паринг по известному классическому адресу — ручной путь без
|
||||
* QR и камеры очков (то самое «подключите вручную в приложении» с экрана
|
||||
* очков). createBond по адресу; очки после BONDED сами уходят из паринг-режима.
|
||||
*/
|
||||
fun pairClassic(address: String): Boolean {
|
||||
val m = mercury ?: return false
|
||||
val res = kotlinx.coroutines.runBlocking { m.pairByRealAddress(address) }
|
||||
return if (res is PairManager.PairingResult.Success) {
|
||||
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
|
||||
setStatus("классический паринг успешен: $address (${res.device.name})")
|
||||
true
|
||||
} else {
|
||||
setError("классический паринг не удался: ${(res as? PairManager.PairingResult.Failed)?.reason}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Классический discovery: находит RayNeo-очки в режиме первого паринга вокруг
|
||||
* телефона (без камеры). Возвращает список найденных (имя, адрес).
|
||||
*/
|
||||
fun discoverGlasses(timeoutMs: Long = 5_000): List<Pair<String, String?>> {
|
||||
val m = mercury ?: return emptyList()
|
||||
val found = kotlinx.coroutines.runBlocking { m.discoverClassic(timeoutMs) }
|
||||
return found.map { it.device.name to it.address }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручной паринг «в один клик»: классический discovery, выбор RayNeo-очков и
|
||||
* createBond. Кнопка вместо QR — для случая, когда очки снова в режиме первого
|
||||
* паринга (слетел бонд) и камера очков недоступна/не считывает QR.
|
||||
*/
|
||||
fun pairClassicAuto(timeoutMs: Long = 7_000): String? {
|
||||
val found = discoverGlasses(timeoutMs)
|
||||
if (found.isEmpty()) {
|
||||
setError("ручной паринг: рядом не найдено ни одного классического устройства — включите очки")
|
||||
return null
|
||||
}
|
||||
val target = found.firstOrNull { (it.first)?.contains("rayneo", ignoreCase = true) == true }
|
||||
if (target == null) {
|
||||
setError("ручной паринг: RayNeo рядом не найден (проверьте, что очки включены и в режиме паринга)")
|
||||
return null
|
||||
}
|
||||
val address = target.second
|
||||
val ok = pairClassic(address ?: return null)
|
||||
if (!ok) return null
|
||||
setStatus("ручной паринг: очки подключены ($address)")
|
||||
return address
|
||||
}
|
||||
|
||||
private fun setStatus(text: String) {
|
||||
log("status", text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = null)
|
||||
}
|
||||
|
||||
private fun setError(text: String) {
|
||||
log("mercury", text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = text)
|
||||
}
|
||||
|
||||
private fun parsePayload(json: String): pw.binom.mercury.model.Payload? = try {
|
||||
val obj = kotlinx.serialization.json.Json.parseToJsonElement(json).jsonObject
|
||||
val el = obj["payload"] ?: return null
|
||||
kotlinx.serialization.json.Json.decodeFromJsonElement(pw.binom.mercury.model.Payload.serializer(), el)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun log(tag: String, msg: String) {
|
||||
pw.binom.viewmate.phone.log(tag, msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import ai.koog.prompt.executor.clients.openai.base.models.OpenAIMessage
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.response.respondTextWriter
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import pw.binom.viewmate.phone.agent.ChatMessage
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* OpenAI-совместимый HTTP-сервер (Ktor-CIO, в стиле [WifiServerTransport]):
|
||||
* - POST /v1/chat/completions — stream=false → JSON chat.completion,
|
||||
* stream=true → SSE-чанки chat.completion.chunk + [DONE];
|
||||
* - GET /v1/models, GET / — самодокументация.
|
||||
*
|
||||
* Мессиджи запроса парсятся DTO koog [OpenAIMessage] (парсер OpenAI-клиента:
|
||||
* роль → типизированный сабтип, content — строка или массив parts,
|
||||
* tool + tool_call_id). Ответы — ручной snake_case JSON: DTO koog для
|
||||
* ответов (OpenAIChatCompletionResponse) сериализуют finishReason в
|
||||
* camelCase (клиентская конвенция), что не OpenAI wire-формат.
|
||||
*
|
||||
* Бэкенд — [PhoneApp.openAiLlm] (текущий выбор модели: локальный LiteRT-LM
|
||||
* или удалённый по ключу); null → 503. Ядро [handleChat] вынесено из
|
||||
* сокета/корутины Ktor — юнит-тестится на JVM.
|
||||
*/
|
||||
object OpenAiHttpServer {
|
||||
|
||||
private const val MODEL_NAME = "viewmate"
|
||||
|
||||
/** Задержка между SSE-фреймами эмулированного стрима (движок не стримит нативно). */
|
||||
private const val CHUNK_DELAY_MS = 25L
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Volatile
|
||||
private var started = false
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
/** Поднимает Ktor-сервер на [PhoneConfig.OPENAI_SERVER_PORT] и печатает все IP устройства. */
|
||||
fun start(app: PhoneApp) {
|
||||
if (started) return
|
||||
started = true
|
||||
try {
|
||||
server = embeddedServer(CIO, port = PhoneConfig.OPENAI_SERVER_PORT, host = "0.0.0.0") {
|
||||
openAiModule(app)
|
||||
}
|
||||
server!!.start(wait = false)
|
||||
log("openai", "OpenAI-HTTP (Ktor-CIO) на 0.0.0.0:${PhoneConfig.OPENAI_SERVER_PORT}")
|
||||
log("openai", "Эндпоинты: /v1/chat/completions (stream=true|false), /v1/models, /")
|
||||
allIps().forEach { ip ->
|
||||
log("openai", "IP: $ip → http://$ip:${PhoneConfig.OPENAI_SERVER_PORT}/v1/chat/completions")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("openai", "не удалось запустить: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Ktor-модуль ----------
|
||||
|
||||
private fun Application.openAiModule(app: PhoneApp) {
|
||||
routing {
|
||||
post("/v1/chat/completions") {
|
||||
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
|
||||
when (val outcome = handleChat(app.openAiLlm(), call.receiveText())) {
|
||||
is ChatOutcome.Json -> call.respondText(
|
||||
text = outcome.body,
|
||||
contentType = ContentType.Application.Json,
|
||||
status = statusCode(outcome.code),
|
||||
)
|
||||
|
||||
is ChatOutcome.Sse -> sendSse(call, outcome)
|
||||
}
|
||||
}
|
||||
get("/v1/models") {
|
||||
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
|
||||
call.respondText(modelsJson(), ContentType.Application.Json)
|
||||
}
|
||||
get("/") {
|
||||
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
|
||||
call.respondText(helpJson(), ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE-стрим: фреймы [ChatOutcome.Sse.frames] с задержкой [CHUNK_DELAY_MS]. */
|
||||
private suspend fun sendSse(call: ApplicationCall, sse: ChatOutcome.Sse) {
|
||||
call.respondTextWriter(
|
||||
contentType = ContentType.parse("text/event-stream; charset=utf-8"),
|
||||
status = HttpStatusCode.OK,
|
||||
) {
|
||||
sse.frames.forEach { frame ->
|
||||
append(frame)
|
||||
flush()
|
||||
delay(CHUNK_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusCode(code: Int): HttpStatusCode = when (code) {
|
||||
200 -> HttpStatusCode.OK
|
||||
400 -> HttpStatusCode.BadRequest
|
||||
502 -> HttpStatusCode.BadGateway
|
||||
503 -> HttpStatusCode.ServiceUnavailable
|
||||
else -> HttpStatusCode(code, "Error")
|
||||
}
|
||||
|
||||
// ---------- ядро (без сокета: юнит-тестится) ----------
|
||||
|
||||
/** Итог обработки /v1/chat/completions: JSON-тело с кодом или список SSE-фреймов. */
|
||||
internal sealed interface ChatOutcome {
|
||||
data class Json(val code: Int, val body: String) : ChatOutcome
|
||||
data class Sse(val frames: List<String>) : ChatOutcome
|
||||
}
|
||||
|
||||
internal suspend fun handleChat(llm: LlmClient?, body: String): ChatOutcome {
|
||||
val req = try {
|
||||
json.decodeFromJsonElement(OaiRequest.serializer(), normalizeOpenAiElement(body))
|
||||
} catch (e: Exception) {
|
||||
return ChatOutcome.Json(400, errorJson("некорректный JSON body", 400))
|
||||
}
|
||||
val (system, history) = runCatching { splitMessages(req) }.getOrElse { e ->
|
||||
return ChatOutcome.Json(400, errorJson(e.message ?: "некорректные messages", 400))
|
||||
}
|
||||
if (history.isEmpty()) return ChatOutcome.Json(400, errorJson("messages не может быть пустым", 400))
|
||||
if (llm == null) {
|
||||
return ChatOutcome.Json(503, errorJson("LLM недоступен: API-ключ не задан или модель не загружена", 503))
|
||||
}
|
||||
val text = try {
|
||||
llm.chat(history, system)
|
||||
} catch (e: Exception) {
|
||||
log("openai", "ошибка LLM: ${e.message}")
|
||||
return ChatOutcome.Json(502, errorJson("ошибка LLM: ${e.message}", 502))
|
||||
}
|
||||
log("openai", "запрос: stream=${req.stream}, msg=${req.messages.size} → ${text.length} симв")
|
||||
|
||||
val id = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "").take(12)
|
||||
val created = System.currentTimeMillis() / 1000
|
||||
return if (req.stream) ChatOutcome.Sse(streamFrames(id, created, text))
|
||||
else ChatOutcome.Json(200, completionJson(id, created, text))
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI wire → DTO koog: tool-сообщения несут `tool_call_id` (snake_case),
|
||||
* а парсер koog ждёт `toolCallId` (camelCase, клиентская конвенция).
|
||||
* Для tool-роли дописываем camelCase-копию (или пустую, если поля нет).
|
||||
* Остальное — без изменений.
|
||||
*/
|
||||
private fun normalizeOpenAiElement(raw: String): JsonElement {
|
||||
val root = json.decodeFromString(JsonElement.serializer(), raw)
|
||||
val obj = root as? JsonObject ?: return root
|
||||
val rawMsgs = obj["messages"] as? JsonArray ?: return root
|
||||
val fixed = rawMsgs.map { el ->
|
||||
val m = el as? JsonObject ?: return@map el
|
||||
if (m["role"]?.jsonPrimitive?.contentOrNull == "tool" && !m.containsKey("toolCallId")) {
|
||||
val tcid = m["tool_call_id"]?.jsonPrimitive?.contentOrNull ?: ""
|
||||
JsonObject(m.toMutableMap().apply { put("toolCallId", JsonPrimitive(tcid)) })
|
||||
} else el
|
||||
}
|
||||
return JsonObject(obj.toMutableMap().apply { put("messages", JsonArray(fixed)) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Мессиджи OpenAI → (system-промпт, история): system/developer склеиваются
|
||||
* в system; user/assistant/tool (tool_call_id) → [ChatMessage].
|
||||
* Content у DTO koog: строка или массив parts — [ai.koog.prompt.executor.clients.openai.base.models.Content.text].
|
||||
*/
|
||||
private fun splitMessages(req: OaiRequest): Pair<String?, List<ChatMessage>> {
|
||||
val systems = mutableListOf<String>()
|
||||
val history = mutableListOf<ChatMessage>()
|
||||
for (m in req.messages) {
|
||||
val content = m.content?.text().orEmpty()
|
||||
when (m) {
|
||||
is OpenAIMessage.System, is OpenAIMessage.Developer -> systems.add(content)
|
||||
is OpenAIMessage.User -> history.add(ChatMessage.of(ChatRole.USER, content))
|
||||
is OpenAIMessage.Assistant -> history.add(ChatMessage.of(ChatRole.ASSISTANT, content))
|
||||
is OpenAIMessage.Tool ->
|
||||
history.add(ChatMessage.of(ChatRole.TOOL, content, toolCallId = m.toolCallId))
|
||||
}
|
||||
}
|
||||
val system = systems.joinToString("\n").ifEmpty { null }
|
||||
return system to history
|
||||
}
|
||||
|
||||
// ---------- константы (без сокета) ----------
|
||||
|
||||
/** Все IPv4/IPv6 адреса интерфейсов + 127.0.0.1 — для логов и тестов. */
|
||||
fun allIps(): List<String> {
|
||||
val ips = linkedSetOf("127.0.0.1")
|
||||
try {
|
||||
for (intf in java.net.NetworkInterface.getNetworkInterfaces()) {
|
||||
if (!intf.isUp || intf.isLoopback) continue
|
||||
for (addr in intf.inetAddresses) {
|
||||
if (addr is java.net.Inet4Address) addr.hostAddress?.let(ips::add)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return ips.toList()
|
||||
}
|
||||
|
||||
private fun completionJson(id: String, created: Long, text: String): String =
|
||||
"{" +
|
||||
"\"id\":\"$id\"," +
|
||||
"\"object\":\"chat.completion\"," +
|
||||
"\"created\":$created," +
|
||||
"\"model\":\"$MODEL_NAME\"," +
|
||||
"\"choices\":[{" +
|
||||
"\"index\":0," +
|
||||
"\"message\":{\"role\":\"assistant\",\"content\":\"${jsonEscape(text)}\"}," +
|
||||
"\"finish_reason\":\"stop\"" +
|
||||
"}]," +
|
||||
"\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0}" +
|
||||
"}"
|
||||
|
||||
/** Фреймы SSE-стрима: роль, по слову (слово + следующие пробелы), finish + [DONE]. */
|
||||
private fun streamFrames(id: String, created: Long, text: String): List<String> {
|
||||
val frames = mutableListOf<String>()
|
||||
frames.add(sseChunk(id, created, role = true))
|
||||
for (m in Regex("""\S+\s*""").findAll(text)) {
|
||||
frames.add(sseChunk(id, created, delta = m.value))
|
||||
}
|
||||
frames.add(sseChunk(id, created, finish = true))
|
||||
frames.add("data: [DONE]\n\n")
|
||||
return frames
|
||||
}
|
||||
|
||||
private fun sseChunk(id: String, created: Long, role: Boolean = false, delta: String? = null, finish: Boolean = false): String {
|
||||
val deltaJson = buildString {
|
||||
if (role) append("\"role\":\"assistant\"")
|
||||
if (delta != null) {
|
||||
if (role) append(',')
|
||||
append("\"content\":\"${jsonEscape(delta)}\"")
|
||||
}
|
||||
}
|
||||
val finishReason = if (finish) "\"stop\"" else "null"
|
||||
val jsonBody = "{" +
|
||||
"\"id\":\"$id\"," +
|
||||
"\"object\":\"chat.completion.chunk\"," +
|
||||
"\"created\":$created," +
|
||||
"\"model\":\"$MODEL_NAME\"," +
|
||||
"\"choices\":[{" +
|
||||
"\"index\":0," +
|
||||
"\"delta\":{$deltaJson}," +
|
||||
"\"finish_reason\":$finishReason" +
|
||||
"}]" +
|
||||
"}"
|
||||
return "data: $jsonBody\n\n"
|
||||
}
|
||||
|
||||
private fun errorJson(message: String, code: Int): String = buildString {
|
||||
val type = if (code in 400..499) "invalid_request_error" else "server_error"
|
||||
append("{\"error\":{\"message\":\"").append(jsonEscape(message)).append("\",")
|
||||
append("\"type\":\"").append(type).append("\",\"code\":null}}")
|
||||
}
|
||||
|
||||
private fun modelsJson(): String =
|
||||
"{\"object\":\"list\",\"data\":[{" +
|
||||
"\"id\":\"$MODEL_NAME\"," +
|
||||
"\"object\":\"model\"," +
|
||||
"\"created\":0," +
|
||||
"\"owned_by\":\"viewmate\"" +
|
||||
"}]"
|
||||
|
||||
private fun helpJson(): String =
|
||||
"{\"message\":\"Viewmate OpenAI-совместимый сервер\"," +
|
||||
"\"endpoints\":[{" +
|
||||
"\"method\":\"POST\",\"path\":\"/v1/chat/completions\"," +
|
||||
"\"description\":\"Chat completion (stream=true|false)\"," +
|
||||
"\"model\":\"$MODEL_NAME\"}]," +
|
||||
"\"models\":[\"$MODEL_NAME\"],\"port\":${PhoneConfig.OPENAI_SERVER_PORT}}"
|
||||
|
||||
private fun jsonEscape(s: String): String =
|
||||
s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
|
||||
// ---------- DTO запроса ----------
|
||||
|
||||
@Serializable
|
||||
private data class OaiRequest(
|
||||
val model: String? = null,
|
||||
val messages: List<OpenAIMessage> = emptyList(),
|
||||
val stream: Boolean = false,
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -15,21 +17,45 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
import pw.binom.viewmate.core.log.FileLogSpool
|
||||
import pw.binom.viewmate.core.log.LokiConfig
|
||||
import pw.binom.viewmate.core.log.SystemClock
|
||||
import pw.binom.viewmate.core.log.lokiLogSink
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.phone.PhoneActions
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.AssistantState
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.AudioSyncInfo
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.phone.agent.AgentToolkit
|
||||
import pw.binom.viewmate.phone.agent.Assistant
|
||||
import pw.binom.viewmate.phone.agent.ChatDao
|
||||
import pw.binom.viewmate.phone.agent.ChatDb
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
||||
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
||||
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LlmPrefs
|
||||
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
|
||||
import pw.binom.viewmate.phone.agent.MediaToolExecutor
|
||||
import pw.binom.viewmate.phone.agent.RemoteLlmClient
|
||||
import pw.binom.viewmate.phone.agent.SkillRegistry
|
||||
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||
import pw.binom.viewmate.phone.stt.PhraseRecognizer
|
||||
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
|
||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||
|
||||
/**
|
||||
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
|
||||
@@ -39,8 +65,13 @@ class PhoneApp : Application() {
|
||||
|
||||
val state: PhoneState = PhoneState()
|
||||
|
||||
/** WS-сервер для очков. */
|
||||
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
||||
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
|
||||
val server: GlassesServer = GlassesServer(
|
||||
port = PhoneConfig.SERVER_PORT,
|
||||
state = state,
|
||||
useBluetooth = PhoneConfig.USE_BLUETOOTH,
|
||||
btContext = this,
|
||||
)
|
||||
|
||||
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
||||
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
||||
@@ -59,6 +90,13 @@ class PhoneApp : Application() {
|
||||
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
||||
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
||||
|
||||
/** Прогресс просмотра по элементам (filesDir/watchprogress.json): возобновление + индикация в каталоге. */
|
||||
val watchProgress: WatchProgressStore by lazy { WatchProgressStore(filesDir) }
|
||||
|
||||
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
|
||||
lateinit var logCollector: BatchingLogCollector
|
||||
private set
|
||||
|
||||
/** История общения с ассистентом (SQLite, "assistant.db"). */
|
||||
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
|
||||
|
||||
@@ -85,76 +123,313 @@ class PhoneApp : Application() {
|
||||
|
||||
private var assistantLock = Any()
|
||||
private var assistant: Assistant? = null
|
||||
private var toolkit: AgentToolkit? = null
|
||||
/** Модель, на которой создан текущий [assistant] — смена выбора пересоздаёт ассистента. */
|
||||
private var assistantChoice: LlmModelChoice? = null
|
||||
/** Активный локальный LLM-клиент (движок закрываем при смене выбора). */
|
||||
private var localLlm: LocalLlmClient? = null
|
||||
|
||||
/** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */
|
||||
/** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */
|
||||
fun localLlmClient(): LocalLlmClient? = localLlm
|
||||
|
||||
/**
|
||||
* LLM-клиент по текущему выбору [llmChoice] для OpenAI-HTTP-сервера
|
||||
* ([OpenAiHttpServer]) — без тул-цикла ассистента (клиент OpenAI API сам
|
||||
* управляет контекстом). Локальный клиент переиспользует [localLlm]
|
||||
* (движок LiteRT один на процесс). null — серверный без ключа или локальная
|
||||
* модель ещё не скачана.
|
||||
*/
|
||||
fun openAiLlm(): LlmClient? = synchronized(assistantLock) {
|
||||
when (_llmChoice.value) {
|
||||
LlmModelChoice.REMOTE ->
|
||||
if (BuildConfig.LLM_API_KEY.isBlank()) null
|
||||
else RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||
|
||||
LlmModelChoice.LOCAL ->
|
||||
if (!localModelFile().isFile) null
|
||||
else localLlm ?: LocalLlmClient(
|
||||
modelFile = localModelFile(),
|
||||
cacheDir = File(cacheDir, "litertlm"),
|
||||
).also { localLlm = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
|
||||
// lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
|
||||
// getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
|
||||
// тот же паттерн, что у chatDao/downloadManager (иначе NPE при старте).
|
||||
val llmPrefs: LlmPrefs by lazy { LlmPrefs(this) }
|
||||
private val _llmChoice: MutableStateFlow<LlmModelChoice> by lazy { MutableStateFlow(llmPrefs.current()) }
|
||||
val llmChoice: StateFlow<LlmModelChoice> by lazy { _llmChoice.asStateFlow() }
|
||||
|
||||
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */
|
||||
private val _localLlmState: MutableStateFlow<LocalLlmModelState> by lazy { MutableStateFlow(initialLocalLlmState()) }
|
||||
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
|
||||
|
||||
private var localLlmDownloadJob: Job? = null
|
||||
private fun initialLocalLlmState(): LocalLlmModelState =
|
||||
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
|
||||
|
||||
/** Файл локальной модели: getExternalFilesDir(null)/gemma-4-E2B-it.litertlm. */
|
||||
fun localModelFile(): File = File(getExternalFilesDir(null) ?: filesDir, Gemma4E2B.FILE_NAME)
|
||||
|
||||
/** Сменить выбор модели: персист + пересоздание ассистента при следующей фразе. */
|
||||
fun setLlmModelChoice(choice: LlmModelChoice) {
|
||||
if (_llmChoice.value == choice) return
|
||||
llmPrefs.set(choice)
|
||||
_llmChoice.value = choice
|
||||
synchronized(assistantLock) { assistant = null }
|
||||
log("llm", "выбрана модель: ${choice.name}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
|
||||
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
|
||||
* Повторный вызов во время скачивания — no-op.
|
||||
*/
|
||||
fun downloadLocalLlmModel() {
|
||||
val current = localLlmDownloadJob
|
||||
if (current != null && current.isActive) return
|
||||
localLlmDownloadJob = scope.launch {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
|
||||
log("llm", "не удалось скачать модель: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Остановить скачивание локальной модели (частичный файл удаляется). */
|
||||
fun cancelLocalLlmDownload() {
|
||||
localLlmDownloadJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу.
|
||||
* LLM-клиент берётся по текущему выбору [llmChoice]: серверный или локальный
|
||||
* LiteRT-LM; при смене выбора ассистент создаётся заново.
|
||||
*/
|
||||
private fun ensureAssistant(): Assistant? {
|
||||
synchronized(assistantLock) {
|
||||
assistant?.let { return it }
|
||||
if (BuildConfig.LLM_API_KEY.isBlank()) return null
|
||||
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) })
|
||||
.also { assistant = it }
|
||||
val choice = _llmChoice.value
|
||||
if (assistant != null) {
|
||||
if (assistantChoice == choice) return assistant
|
||||
assistant = null
|
||||
}
|
||||
if (choice == LlmModelChoice.REMOTE && BuildConfig.LLM_API_KEY.isBlank()) {
|
||||
assistantChoice = choice
|
||||
return null
|
||||
}
|
||||
runCatching { localLlm?.close() }
|
||||
localLlm = null
|
||||
val llm = when (choice) {
|
||||
LlmModelChoice.REMOTE -> RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||
LlmModelChoice.LOCAL -> {
|
||||
// cacheDir — подкаталог cache/litertlm: в 11:46 именно с ним
|
||||
// (без спекуляции) движок инициализировался за 12с; с корнем
|
||||
// cache/ initialize() виснет. GPU-делегату нужен СВОЙ каталог.
|
||||
val cacheDir = File(cacheDir, "litertlm")
|
||||
LocalLlmClient(modelFile = localModelFile(), cacheDir = cacheDir).also { localLlm = it }
|
||||
}
|
||||
}
|
||||
val toolsets = ToolsetRegistry()
|
||||
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
|
||||
val tk = toolkit ?: AgentToolkit(
|
||||
skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()),
|
||||
toolsets = toolsets,
|
||||
showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } },
|
||||
).also { toolkit = it }
|
||||
return Assistant(
|
||||
chatDao = chatDao,
|
||||
chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
|
||||
toolkit = tk,
|
||||
// Нативный тул-канал LiteRT: только локальная модель видит схемы
|
||||
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
|
||||
nativeTools = choice == LlmModelChoice.LOCAL,
|
||||
// Локальной модели нужно время: инициализация движка ~17с на
|
||||
// CPU + генерация. Серверная укладывается в 30с, локальной
|
||||
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
|
||||
timeoutMs = if (choice == LlmModelChoice.LOCAL) 300_000L else 30_000L,
|
||||
).also {
|
||||
assistant = it
|
||||
assistantChoice = choice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Тулсет media: тулы работают через [JellyfinMediaSource] (каталог) и уровня-приложения [agentPlayMedia] («включить на очках»). */
|
||||
private fun mediaToolExecutor(): MediaToolExecutor = MediaToolExecutor(
|
||||
media = JellyfinMediaSource(jellyfin) { userId.value },
|
||||
player = { id, seekMs -> agentPlayMedia(id, seekMs) },
|
||||
)
|
||||
|
||||
/**
|
||||
* Тул ассистента media_play: проверки (очки на связи, видео скачано на очки,
|
||||
* звук скачан на телефон) + запуск через [PhoneActions.watch] (механизм DetailsScreen)
|
||||
* + звук на телефоне выбранной дорожкой с позиции [seekMs]. Возвращает текст для модели.
|
||||
*/
|
||||
private suspend fun agentPlayMedia(id: String, seekMs: Long): String {
|
||||
val hub = server.hub
|
||||
if (hub.connected.value == 0) return "Очки не подключены."
|
||||
val mediaDir = hub.mediaDir.value
|
||||
if (mediaDir.isNullOrBlank()) return "Очки ещё не опубликовали медиакаталог — попробуйте позже."
|
||||
if (id !in hub.downloadedItemIds.value) return "Видео не скачано на очки. Скачайте его вручную через телефон."
|
||||
if (!downloadManager.audioReady(id)) return "Звук не готов: сначала скачайте аудио на телефон."
|
||||
val acts = actions ?: return "Телефон ещё не готов."
|
||||
val audioIndex = state.currentAudioIndex
|
||||
log("agent", "play: $id, звук #$audioIndex, seek=${seekMs} мс")
|
||||
|
||||
val result = acts.watch(
|
||||
itemId = id,
|
||||
audioIndex = audioIndex,
|
||||
localVideoPath = "file://$mediaDir/$id/video.mkv",
|
||||
phoneAudioReady = true,
|
||||
startPositionMs = seekMs,
|
||||
)
|
||||
if (!result.contains("включил")) return result
|
||||
|
||||
// Звук на телефоне (с позиции, как DetailsScreen): локальный ogg или S3-стрим.
|
||||
if (audioSync.isActive) audioSync.stop()
|
||||
val audio = resolveAudioSourceDetailed(
|
||||
mirrorFiles = mirror.mirrorByItem(id)?.files,
|
||||
audioIndex = audioIndex,
|
||||
localAudioFile = { downloadManager.audioFile(id, it) },
|
||||
)
|
||||
if (audio.source == null) return "Видео включено, но источники звука отсутствуют: ${audio.reason}"
|
||||
audioSync.play(audio.source, seekMs.coerceAtLeast(0L))
|
||||
return "Включено: «${state.currentItem?.Name ?: id}»"
|
||||
}
|
||||
|
||||
/**
|
||||
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
||||
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
||||
*/
|
||||
private suspend fun assistantLoop() {
|
||||
for (phrase in assistantChannel) {
|
||||
log("assistant", "получена фраза: $phrase")
|
||||
val assistant = ensureAssistant()
|
||||
if (assistant == null) {
|
||||
log("assistant", "LLM-ключ не задан — фраза пропущена")
|
||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
|
||||
continue
|
||||
}
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
val answer = runCatching { assistant.process(phrase) }
|
||||
val answer = runCatching {
|
||||
val active = _activeSessionId.value
|
||||
if (active != null) {
|
||||
assistant.processInSession(active, phrase)
|
||||
} else {
|
||||
assistant.process(phrase)
|
||||
}
|
||||
}
|
||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
server.hub.broadcast(ShowText(answer))
|
||||
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||
server.hub.broadcast(chatHistoryMsg())
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
||||
* История активной (или последней) сессии для очков: только роли user/assistant,
|
||||
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
||||
*/
|
||||
private fun chatHistoryMsg(): ChatHistoryMsg {
|
||||
val id = _activeSessionId.value ?: chatDao.latestSessionId()
|
||||
if (id == null) return ChatHistoryMsg(sessionId = null, messages = emptyList())
|
||||
val items = chatDao.messagesForSession(id)
|
||||
.filter { it.role == ChatRole.USER || it.role == ChatRole.ASSISTANT }
|
||||
.map { ChatHistoryItem(role = it.role.wire, content = it.content) }
|
||||
return ChatHistoryMsg(sessionId = id.toString(), messages = items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка текста в ассистента из чата на телефоне (не с очков).
|
||||
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
|
||||
*/
|
||||
fun sendToAssistant(text: String) {
|
||||
scope.launch { assistantChannel.send(text) }
|
||||
}
|
||||
|
||||
/** Активная сессия чата (для UI телефона). null — «последняя» (дефолт). */
|
||||
private val _activeSessionId = MutableStateFlow<Long?>(null)
|
||||
val activeSessionId: StateFlow<Long?> = _activeSessionId
|
||||
|
||||
/** Создать новую сессию и сделать её активной. */
|
||||
fun newChatSession(): Long {
|
||||
val id = chatDao.createSession()
|
||||
_activeSessionId.value = id
|
||||
return id
|
||||
}
|
||||
|
||||
/** Переключить активную сессию. null — вернуться к «последней». */
|
||||
fun selectChatSession(id: Long?) {
|
||||
_activeSessionId.value = id
|
||||
}
|
||||
|
||||
/** Сбросить (очистить сообщения) активной или последней сессии. */
|
||||
fun resetChatSession() {
|
||||
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: return
|
||||
chatDao.clearSession(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models —
|
||||
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
||||
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
|
||||
* на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
|
||||
*/
|
||||
private fun ensureStt(): SttStreamer? {
|
||||
synchronized(sttLock) {
|
||||
sttStreamer?.let { return it }
|
||||
val modelsDir = File(filesDir, "models")
|
||||
val vadModel = File(modelsDir, "silero_vad.onnx")
|
||||
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
||||
val tokens = File(modelsDir, "small-tokens.txt")
|
||||
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
||||
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||
val qwenConv = File(modelsDir, "conv_frontend.onnx")
|
||||
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
|
||||
val qwenDec = File(modelsDir, "decoder.int8.onnx")
|
||||
val qwenTok = File(modelsDir, "tokenizer")
|
||||
if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
|
||||
log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||
return null
|
||||
}
|
||||
val whisper = WhisperStt(
|
||||
encoderPath = encoder.absolutePath,
|
||||
decoderPath = decoder.absolutePath,
|
||||
tokensPath = tokens.absolutePath,
|
||||
val stt: PhraseRecognizer = Qwen3AsrStt(
|
||||
convFrontendPath = qwenConv.absolutePath,
|
||||
encoderPath = qwenEnc.absolutePath,
|
||||
decoderPath = qwenDec.absolutePath,
|
||||
tokenizerDir = qwenTok.absolutePath,
|
||||
numThreads = 4,
|
||||
hotwords = STT_HOTWORDS,
|
||||
)
|
||||
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
||||
File(filesDir, "stt_dump_phone.raw")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)")
|
||||
val s = SttStreamer(
|
||||
stt = whisper,
|
||||
vadModelPath = vadModel.absolutePath,
|
||||
onPhrase = { phrase, full ->
|
||||
log("stt", "фраза: $phrase")
|
||||
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
||||
scope.launch { assistantChannel.send(phrase) }
|
||||
},
|
||||
stt = stt,
|
||||
onSilence30s = {
|
||||
log("stt", "тишина 30 с — автo-отмена")
|
||||
scope.launch { server.hub.broadcast(SttCancel()) }
|
||||
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||
},
|
||||
onAutoFinished = { full ->
|
||||
log("stt", "тишина 30 с — ВЕСЬ ТЕКСТ: $full")
|
||||
// Тот же путь, что и результат по клику: в очки SttDone + в LLM.
|
||||
scope.launch { server.hub.broadcast(SttDone(full)) }
|
||||
scope.launch { assistantChannel.send(full.trim()) }
|
||||
},
|
||||
dumpPcm = dumpPcm,
|
||||
)
|
||||
sttStreamer = s
|
||||
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
||||
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||
log("stt", "SttStreamer готов (qwen3_asr)")
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -189,10 +464,39 @@ class PhoneApp : Application() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
PlaybackService.start(this)
|
||||
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
|
||||
|
||||
MercuryBridge.init(this)
|
||||
|
||||
server.start()
|
||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||
DebugHttpServer.start(this)
|
||||
OpenAiHttpServer.start(this)
|
||||
nsd.publish()
|
||||
server.hub.sttFactory = { ensureStt() }
|
||||
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
|
||||
|
||||
// Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki
|
||||
// выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool.
|
||||
logCollector = BatchingLogCollector(
|
||||
device = "phone",
|
||||
sink = lokiLogSink(),
|
||||
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64),
|
||||
clock = SystemClock(),
|
||||
flushIntervalMs = 60_000,
|
||||
maxBatchEntries = 500,
|
||||
)
|
||||
logCollector.start(scope)
|
||||
attachLogCollector(logCollector)
|
||||
pw.binom.viewmate.core.logCollector = logCollector
|
||||
server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) }
|
||||
log(
|
||||
"server",
|
||||
"лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " +
|
||||
"спул: filesDir/logspool)",
|
||||
)
|
||||
|
||||
scope.launch { assistantLoop() }
|
||||
|
||||
val lastPositionAt = AtomicLong(0)
|
||||
@@ -200,11 +504,25 @@ class PhoneApp : Application() {
|
||||
|
||||
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
||||
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
|
||||
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex ->
|
||||
// + прогресс просмотра (resume + индикация в каталоге)
|
||||
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs, durationMs ->
|
||||
lastPositionAt.set(System.currentTimeMillis())
|
||||
lastWasPlaying.set(playing)
|
||||
audioSync.sync(positionMs, playing)
|
||||
audioSync.sync(positionMs, playing, sentTs)
|
||||
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
|
||||
if (itemId != null) {
|
||||
scope.launch(Dispatchers.IO) { watchProgress.upsert(itemId, positionMs, durationMs) }
|
||||
}
|
||||
}
|
||||
|
||||
// Индикатор аудио-синхронизации в статус-панели очков: |звук − видео| в мс
|
||||
// на каждый sync-цикл (~5с); active=false (видео на очках не играет) — панель скрывает.
|
||||
scope.launch {
|
||||
audioSync.diffMs.collect { diff ->
|
||||
server.hub.broadcast(
|
||||
AudioSyncInfo(diffMs = diff.toInt(), active = audioSync.isActive && audioSync.videoPlaying),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту
|
||||
@@ -264,6 +582,7 @@ class PhoneApp : Application() {
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
PlaybackService.stop(this)
|
||||
nsd.unpublish()
|
||||
sttStreamer?.close()
|
||||
server.stop()
|
||||
@@ -273,5 +592,8 @@ class PhoneApp : Application() {
|
||||
companion object {
|
||||
lateinit var instance: PhoneApp
|
||||
private set
|
||||
|
||||
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,23 @@ object PhoneConfig {
|
||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||
const val SERVER_PORT = 8080
|
||||
|
||||
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
||||
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
||||
/** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */
|
||||
const val OPENAI_SERVER_PORT = 8800
|
||||
|
||||
/**
|
||||
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
|
||||
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
|
||||
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
|
||||
*/
|
||||
const val USE_BLUETOOTH = true
|
||||
|
||||
/**
|
||||
* Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит).
|
||||
* 10 с = два пропущенных пинга: 5-секундные пинги очков джиттят (интервалы 3.8–6.3 с —
|
||||
* SPP-запись / main-поток очков задерживаются), и порог 5 с ложно ставил звук на паузу
|
||||
* при каждом позднем пинге (слышимый провал 200–800 мс, «звук возобновился с того же места»).
|
||||
*/
|
||||
const val AUDIO_SILENCE_PAUSE_MS = 10_000L
|
||||
|
||||
/** Период проверки watchdog. */
|
||||
const val WATCHDOG_TICK_MS = 2_000L
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Foreground service типа mediaPlayback: держит процесс телефона живым (WS-сервер +
|
||||
* аудио-плеер) при выключенном экране. Без него HONOR замораживает/убивает процесс
|
||||
* в фоне → очки теряют связь (видео паузится) и звук встаёт. Является спутником
|
||||
* WS-сервера: стартует в PhoneApp.onCreate, останавливается в onTerminate.
|
||||
*/
|
||||
class PlaybackService : Service() {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "viewmate_playback"
|
||||
const val NOTIFICATION_ID = 1
|
||||
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(
|
||||
context, Intent(context, PlaybackService::class.java)
|
||||
)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, PlaybackService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "ViewMate playback",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) }
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle("ViewMate")
|
||||
.setContentText("Звук на телефоне при просмотре на очках")
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
// держим CPU и WiFi живыми при выключенном экране
|
||||
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
|
||||
?.apply { acquire() }
|
||||
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
|
||||
?.apply { acquire() }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
START_STICKY
|
||||
|
||||
override fun onDestroy() {
|
||||
wakeLock?.takeIf { it.isHeld }?.release()
|
||||
wifiLock?.takeIf { it.isHeld }?.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Прогресс просмотра элемента: как далеко досмотрели и длительность видео. */
|
||||
@Serializable
|
||||
data class WatchProgress(
|
||||
val positionMs: Long,
|
||||
/** Длительность видео, мс; -1 = неизвестно (старые очки не сообщали durationMs). */
|
||||
val durationMs: Long = -1L,
|
||||
) {
|
||||
/** Процент просмотренного (0..100); null — длительность неизвестна. */
|
||||
fun watchedPercent(): Int? =
|
||||
if (durationMs <= 0L) null else ((positionMs.toDouble() / durationMs) * 100.0).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class WatchProgressFile(val entries: Map<String, WatchProgress>)
|
||||
|
||||
/**
|
||||
* Прогресс просмотра по элементам: `filesDir/watchprogress.json`.
|
||||
*
|
||||
* Заполняется из PlaybackPosition очков (раз в 5с, только при продвижении вперёд),
|
||||
* используется в двух местах: (1) возобновление просмотра с того же места по «▶ Смотреть»
|
||||
* и (2) индикация просмотренного на карточках каталога.
|
||||
* Ключ — itemId (не зависит от дорожки звука — позиция общая).
|
||||
* Конструктор принимает директорию (в проде — filesDir) для JVM-тестируемости.
|
||||
*/
|
||||
class WatchProgressStore(dir: File) {
|
||||
|
||||
companion object {
|
||||
/** Понять «новое начало»: просмотрено меньше 15 с — возобновляем с начала. */
|
||||
const val MIN_RESUME_MS = 15_000L
|
||||
|
||||
/** 98% и больше — элемент досмотрен: в следующий раз начинаем сначала. */
|
||||
const val FINISHED_RATIO = 0.98
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
|
||||
private val file = File(dir, "watchprogress.json")
|
||||
private val lock = Any()
|
||||
private val entries = LinkedHashMap<String, WatchProgress>()
|
||||
|
||||
private val _flow = MutableStateFlow<Map<String, WatchProgress>>(emptyMap())
|
||||
/** Снимки прогресса по itemId — для карточек каталога. */
|
||||
val flow: StateFlow<Map<String, WatchProgress>> = _flow.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
_flow.value = entries.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать позицию элемента. На диск пишем только при продвижении вперёд
|
||||
* или когда впервые известна длительность; дубликаты (пауза) — без записи.
|
||||
* Позиция дальше длительности — мусор ExoPlayer (гашение экрана): игнор.
|
||||
*/
|
||||
fun upsert(itemId: String, positionMs: Long, durationMs: Long = -1L) {
|
||||
if (positionMs < 0L) return
|
||||
synchronized(lock) {
|
||||
val old = entries[itemId] ?: WatchProgress(0L)
|
||||
val knownDuration = if (durationMs > 0L) durationMs else old.durationMs
|
||||
if (knownDuration > 0L && positionMs > knownDuration) {
|
||||
log("progress", "упсерт отклонён: позиция $positionMs мс > длительность $knownDuration мс ($itemId)")
|
||||
return
|
||||
}
|
||||
val advances = positionMs > old.positionMs ||
|
||||
(durationMs > 0L && durationMs != old.durationMs)
|
||||
if (!advances) return
|
||||
entries[itemId] = WatchProgress(positionMs, if (durationMs > 0L) durationMs else old.durationMs)
|
||||
persist()
|
||||
_flow.value = entries.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиция для возобновления просмотра: null = начинать с начала
|
||||
* (не смотрели, смотрели меньше [MIN_RESUME_MS] или досмотрели до [FINISHED_RATIO]).
|
||||
*/
|
||||
fun resumePosition(itemId: String): Long? {
|
||||
val e = synchronized(lock) { entries[itemId] } ?: return null
|
||||
return when {
|
||||
e.positionMs < MIN_RESUME_MS -> null
|
||||
e.durationMs > 0L && e.positionMs >= e.durationMs * FINISHED_RATIO -> null
|
||||
else -> e.positionMs
|
||||
}
|
||||
}
|
||||
|
||||
/** Процент просмотренного (0..100); null — элемент не смотрели или длительность неизвестна. */
|
||||
fun watchedPercent(itemId: String): Int? {
|
||||
val e = synchronized(lock) { entries[itemId] } ?: return null
|
||||
return e.watchedPercent()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val text = runCatching { file.readText() }.getOrNull() ?: return
|
||||
val parsed = runCatching { json.decodeFromString(WatchProgressFile.serializer(), text).entries }.getOrNull()
|
||||
if (parsed != null) {
|
||||
entries.putAll(parsed)
|
||||
} else {
|
||||
log("progress", "watchprogress.json не прочитан — начинаю пустым")
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
val result = runCatching { json.encodeToString(WatchProgressFile.serializer(), WatchProgressFile(entries.toMap())) }
|
||||
result.onSuccess { text ->
|
||||
runCatching { file.writeText(text) }.onFailure { log("progress", "запись watchprogress.json: ${it.message}") }
|
||||
}.onFailure { log("progress", "сериализация watchprogress.json: ${it.message}") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.delay
|
||||
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* WiFi-транспорт, сторона телефона («сервер») — TASK-transport.md п.2:
|
||||
* [GlassesServerTransport] на Ktor-CIO-сервере (WebSocket [GLASSES_WS_PATH]).
|
||||
* Сессии — [connId] (UUID-строки, не WS-объекты): мост между WS-сессией и
|
||||
* connId живёт здесь, [GlassesHub] знает только строки.
|
||||
*/
|
||||
class WifiServerTransport(
|
||||
private val port: Int = PhoneConfig.SERVER_PORT,
|
||||
) : GlassesServerTransport {
|
||||
|
||||
override val name = "wifi"
|
||||
|
||||
private val sessionByConn = ConcurrentHashMap<String, DefaultWebSocketServerSession>()
|
||||
@Volatile
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
/** Бесконечно принимать очки: старт Ktor-сервера + холд (пока [close]). */
|
||||
override suspend fun accept(
|
||||
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||
onConnected: (connId: String) -> Unit,
|
||||
onDisconnected: (connId: String) -> Unit,
|
||||
) {
|
||||
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
|
||||
glassesWsModule(onMessage, onConnected, onDisconnected)
|
||||
}
|
||||
server!!.start(wait = false)
|
||||
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
|
||||
while (!closed) {
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ktor-модуль: WS-роут очков; сессия адресуется connId (UUID), события — колбэки [accept]. */
|
||||
private fun Application.glassesWsModule(
|
||||
onMessage: suspend (connId: String, json: String) -> Unit,
|
||||
onConnected: (connId: String) -> Unit,
|
||||
onDisconnected: (connId: String) -> Unit,
|
||||
) {
|
||||
install(WebSockets) {
|
||||
pingPeriodMillis = 5_000
|
||||
timeoutMillis = 5_000
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
routing {
|
||||
webSocket(GLASSES_WS_PATH) {
|
||||
val connId = UUID.randomUUID().toString()
|
||||
sessionByConn[connId] = this
|
||||
onConnected(connId)
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
onMessage(connId, frame.readText())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("server", "WS-ошибка: ${e.message}")
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
sessionByConn.remove(connId)
|
||||
onDisconnected(connId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun send(connId: String, json: String) {
|
||||
sessionByConn[connId]?.send(json)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
runCatching { server?.stop(1_000, 3_000) }
|
||||
server = null
|
||||
sessionByConn.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import pw.binom.viewmate.core.log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Механика агента: ядро тулов + тулсеты + скиллы + сборка системного
|
||||
* промпта. Чистая JVM-логика (без Android), юнит-тестируется.
|
||||
*
|
||||
* Три состояния промпта (пересобирается на каждый запрос):
|
||||
* 1. Набор не активен: ядро + каталог тулсетов + индекс скиллов.
|
||||
* 2. Активация: enable пересобирает промпт, сам вызов в историю НЕ пишется
|
||||
* («системная тула-невидимка»).
|
||||
* 3. Активен: ядро + описания тул набора + «Активные тулсеты: имя»;
|
||||
* скилл, связанный с набором (имя скилла = имя тулсета), УБРАН из промпта.
|
||||
*
|
||||
* Бесшовная подмена: тул набора при НЕактивном наборе не выполняется вслепую —
|
||||
* подменяется на enable_toolset|набор и модели явно сообщается, что вызов ещё
|
||||
* НЕ выполнялся (иначе она выдумает результат). Если аргументы совпадают со
|
||||
* схемой и исполнитель есть — вызов выполняется сразу, без второго круга.
|
||||
*/
|
||||
class AgentToolkit(
|
||||
private val skills: SkillRegistry,
|
||||
private val toolsets: ToolsetRegistry,
|
||||
private val showText: ((String) -> Unit)? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val TOOL_TIME = "get_current_time"
|
||||
const val TOOL_SHOW_TEXT = "show_text"
|
||||
const val TOOL_READ_SKILL = "read_skill"
|
||||
const val TOOL_ENABLE = "enable_toolset"
|
||||
const val TOOL_DISABLE = "disable_toolset"
|
||||
|
||||
/** Ядро: 5 тул, всегда в промпте, компактно. */
|
||||
val CORE_TOOL_NAMES: List<String> =
|
||||
listOf(TOOL_TIME, TOOL_SHOW_TEXT, TOOL_READ_SKILL, TOOL_ENABLE, TOOL_DISABLE)
|
||||
|
||||
private val CORE_LINES = listOf(
|
||||
"get_current_time — текущие дата, время, часовой пояс (ISO 8601).",
|
||||
"show_text|text — показать текст на очках.",
|
||||
"read_skill|имя — прочитать полное содержимое скилла по имени.",
|
||||
"enable_toolset|имя — активировать тулсет (живёт 10 мин от последнего вызова его тул).",
|
||||
"disable_toolset|имя — деактивировать тулсет.",
|
||||
)
|
||||
}
|
||||
|
||||
/** Одноразовые напоминания по сессиям (невидимые тулы: «уже активен», «набора нет»). */
|
||||
private val pendingNotes = ConcurrentHashMap<Long, String>()
|
||||
|
||||
// ---------- сборка промпта ----------
|
||||
|
||||
/**
|
||||
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
|
||||
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
|
||||
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
|
||||
*
|
||||
* [nativeTools] — нативный тул-канал (локальная модель, LiteRT): тулы
|
||||
* модель видит через JSON-схемы в движке, строку текстового протокола
|
||||
* TOOL_CALL в промпт НЕ кладем (иначе 2B-модель путает каналы).
|
||||
*/
|
||||
fun systemPrompt(sessionId: Long, nativeTools: Boolean = false): String {
|
||||
val note = pendingNotes.remove(sessionId)
|
||||
val activeSets = toolsets.activeNames(sessionId)
|
||||
val sb = StringBuilder()
|
||||
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
|
||||
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
|
||||
if (nativeTools) {
|
||||
sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
|
||||
.append("После получения результата тула ответь пользователю текстом.")
|
||||
} else {
|
||||
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
|
||||
.append("После получения результата тула ответь пользователю текстом.")
|
||||
}
|
||||
sb.append("\n\nЯдро:")
|
||||
CORE_LINES.forEach { sb.append('\n').append(it) }
|
||||
val catalog = toolsets.catalogLines()
|
||||
if (catalog.isNotEmpty()) {
|
||||
sb.append("\n\nКаталог тулсетов (активация — enable_toolset|имя):")
|
||||
catalog.forEach { sb.append('\n').append(it) }
|
||||
}
|
||||
if (activeSets.isNotEmpty()) {
|
||||
sb.append("\n\nАктивные тулсеты: ").append(activeSets.joinToString(", "))
|
||||
activeSets.forEach { name ->
|
||||
toolsets.toolset(name)?.toolLines()?.forEach { sb.append('\n').append(it) }
|
||||
}
|
||||
}
|
||||
val skillLines = skills.index(excluded = activeSets.toSet())
|
||||
if (skillLines.isNotEmpty()) {
|
||||
sb.append("\n\nСкиллы (read_skill|имя):")
|
||||
skillLines.forEach { sb.append('\n').append(it) }
|
||||
}
|
||||
if (note != null) sb.append("\n\nВажно: ").append(note)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Нейтральные схемы тул для [LlmClient.chatWithTools] (нативный канал):
|
||||
* ядро (5 тул) + тулы АКТИВНЫХ тулсетов (с полными именами `набор_тул`).
|
||||
* Неактивные тулсеты сюда не попадают — модель увидит их только после
|
||||
* enable_toolset (в следующем callLlm схема пересобирается).
|
||||
*/
|
||||
fun toolSpecs(sessionId: Long): List<ToolSpec> {
|
||||
val out = mutableListOf(
|
||||
ToolSpec(TOOL_TIME, "Текущие дата, время и часовой пояс (ISO 8601)"),
|
||||
ToolSpec(TOOL_SHOW_TEXT, "Показать текст на очках", listOf("text")),
|
||||
ToolSpec(TOOL_READ_SKILL, "Прочитать полное содержимое скилла по имени", listOf("name")),
|
||||
ToolSpec(TOOL_ENABLE, "Активировать тулсет (живёт 10 мин от последнего вызова его тул)", listOf("name")),
|
||||
ToolSpec(TOOL_DISABLE, "Деактивировать тулсет", listOf("name")),
|
||||
)
|
||||
for (setName in toolsets.activeNames(sessionId)) {
|
||||
val ts = toolsets.toolset(setName) ?: continue
|
||||
ts.tools.forEach { s -> out += ToolSpec(ts.toolFull(s), s.description, s.args) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- парсинг аргументов ----------
|
||||
|
||||
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
|
||||
fun schemaFor(tool: String): List<String> = when (tool) {
|
||||
TOOL_TIME -> emptyList()
|
||||
TOOL_SHOW_TEXT -> listOf("text")
|
||||
TOOL_READ_SKILL -> listOf("name")
|
||||
TOOL_ENABLE, TOOL_DISABLE -> listOf("name")
|
||||
else -> toolsets.signatureOf(tool)?.args ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||
* одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
|
||||
* (общая с RemoteLlmClient), метод оставлен для API/тестов.
|
||||
*/
|
||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
|
||||
parseToolCallArgs(raw, expected)
|
||||
|
||||
// ---------- диспач вызовов ----------
|
||||
|
||||
/**
|
||||
* Результат диспача:
|
||||
* [Run] — результат пишется в историю (TOOL-сообщение);
|
||||
* [Silent] — невидимая системная тула (enable/disable_toolset): в историю
|
||||
* НЕ пишется, просто пересобирается промпт на следующем запросе.
|
||||
*/
|
||||
sealed interface Dispatch {
|
||||
class Run(val result: String) : Dispatch
|
||||
object Silent : Dispatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Диспач вызова тула: ядро + префиксная маршрутизация в тулсеты.
|
||||
* Все тулы идемпотентные и прощающие; аргументы валидируются по реестру.
|
||||
*/
|
||||
fun dispatch(sessionId: Long, tool: String, args: Map<String, String>): Dispatch {
|
||||
when (tool) {
|
||||
TOOL_TIME -> return Dispatch.Run(TimeTool.invoke("{}"))
|
||||
|
||||
TOOL_SHOW_TEXT -> {
|
||||
val text = args["text"]
|
||||
if (text.isNullOrBlank()) {
|
||||
return Dispatch.Run(
|
||||
"show_text: аргументы неверны, не хватает \"text\". Схема: show_text|text=<текст>."
|
||||
)
|
||||
}
|
||||
if (showText == null) {
|
||||
return Dispatch.Run("Текст \"$text\" не показан: отображение на очки не подключено.")
|
||||
}
|
||||
showText(text)
|
||||
log("toolset", "show_text (сессия $sessionId): $text")
|
||||
return Dispatch.Run("Показано на очках: $text")
|
||||
}
|
||||
|
||||
TOOL_READ_SKILL -> {
|
||||
val name = args["name"]
|
||||
if (name.isNullOrBlank()) {
|
||||
return Dispatch.Run("read_skill: аргументы неверны, не хватает \"name\". Схема: read_skill|имя=<скилл>.")
|
||||
}
|
||||
val content = skills.readFull(name)
|
||||
?: return Dispatch.Run("Скилл \"$name\" не найден. " + skillsAvailable())
|
||||
log("toolset", "read_skill (сессия $sessionId): $name")
|
||||
return Dispatch.Run(content)
|
||||
}
|
||||
|
||||
TOOL_ENABLE -> {
|
||||
val name = args["name"]
|
||||
if (name.isNullOrBlank()) {
|
||||
return Dispatch.Run("enable_toolset: аргументы неверны, не хватает \"name\". Схема: enable_toolset|имя=<тулсет>.")
|
||||
}
|
||||
when (val r = toolsets.enable(sessionId, name)) {
|
||||
is EnableResult.Activated ->
|
||||
setNote(sessionId, if (r.alreadyActive) "Тулсет \"$name\" уже активен, таймаут продлён." else null)
|
||||
is EnableResult.Unknown -> setNote(sessionId, r.message)
|
||||
}
|
||||
log("toolset", "enable_toolset (сессия $sessionId): $name")
|
||||
return Dispatch.Silent
|
||||
}
|
||||
|
||||
TOOL_DISABLE -> {
|
||||
val name = args["name"]
|
||||
if (name.isNullOrBlank()) {
|
||||
return Dispatch.Run("disable_toolset: аргументы неверны, не хватает \"name\". Схема: disable_toolset|имя=<тулсет>.")
|
||||
}
|
||||
when (val r = toolsets.disable(sessionId, name)) {
|
||||
is DisableResult.Disabled -> setNote(sessionId, null)
|
||||
is DisableResult.NotActive -> setNote(sessionId, r.message)
|
||||
}
|
||||
log("toolset", "disable_toolset (сессия $sessionId): $name")
|
||||
return Dispatch.Silent
|
||||
}
|
||||
}
|
||||
|
||||
// Префиксная маршрутизация: media_* → набор media.
|
||||
val ts = toolsets.findByPrefix(tool)
|
||||
?: return Dispatch.Run("Тул \"$tool\" не найден. " + available())
|
||||
val sig = ts.signatureOf(tool)
|
||||
?: return Dispatch.Run(
|
||||
"Тул \"$tool\" в тулсете \"${ts.name}\" нет. Тулы тулсета: " +
|
||||
ts.toolLines().joinToString("; ") + "."
|
||||
)
|
||||
|
||||
if (!toolsets.isActive(sessionId, ts.name)) {
|
||||
// Бесшовная подмена: набор неактивен — активируем его (как будто
|
||||
// вызван enable_toolset|набор) и явно говорим, что вызов ещё не выполнен.
|
||||
toolsets.enable(sessionId, ts.name)
|
||||
log("toolset", "подмена $tool → enable_toolset|${ts.name} (сессия $sessionId)")
|
||||
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
|
||||
val executor = toolsets.executor(ts.name)
|
||||
if (missing.isEmpty() && executor != null) {
|
||||
val result = executor.execute(ts.name, tool, args)
|
||||
toolsets.touch(sessionId, ts.name)
|
||||
return Dispatch.Run(result)
|
||||
}
|
||||
return Dispatch.Run(substitutionMessage(ts, tool, sig))
|
||||
}
|
||||
|
||||
val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() }
|
||||
if (missing.isNotEmpty()) {
|
||||
val schema = buildString {
|
||||
append(tool)
|
||||
if (sig.args.isNotEmpty()) append('|').append(sig.args.joinToString(","))
|
||||
}
|
||||
return Dispatch.Run(
|
||||
"$tool: аргументы неверны, не хватает обязательных: ${missing.joinToString(", ")}. Схема: $schema."
|
||||
)
|
||||
}
|
||||
val executor = toolsets.executor(ts.name)
|
||||
?: return Dispatch.Run("Тулсет \"${ts.name}\" активен, но его тулы ещё не реализованы.")
|
||||
val result = executor.execute(ts.name, tool, args)
|
||||
toolsets.touch(sessionId, ts.name)
|
||||
log("toolset", "$tool (сессия $sessionId) → $result")
|
||||
return Dispatch.Run(result)
|
||||
}
|
||||
|
||||
/** Сообщение подмены: набор активирован + реестр + явная пометка «вызов ещё не выполнен». */
|
||||
private fun substitutionMessage(ts: Toolset, tool: String, sig: ToolSignature): String {
|
||||
val registry = ts.toolLines().joinToString("; ")
|
||||
val usage = when {
|
||||
sig.args.isEmpty() -> "Для выполнения вызови $tool."
|
||||
else -> "Для выполнения вызови $tool с аргументами: ${sig.argNames().joinToString(", ")}."
|
||||
}
|
||||
return "Тулсет ${ts.name} активирован. Реестр: $registry. " +
|
||||
"Вызов $tool ещё НЕ выполнялся. $usage"
|
||||
}
|
||||
|
||||
private fun setNote(sessionId: Long, note: String?) {
|
||||
if (note == null) pendingNotes.remove(sessionId) else pendingNotes[sessionId] = note
|
||||
}
|
||||
|
||||
private fun skillsAvailable(): String =
|
||||
"Доступные скиллы: " + skills.names().joinToString(", ").ifBlank { "нет скиллов" }
|
||||
|
||||
private fun available(): String {
|
||||
val sets = toolsets.names()
|
||||
return "Доступны: " + CORE_TOOL_NAMES.joinToString(", ") +
|
||||
if (sets.isEmpty()) ". Тулсетов нет." else ". Тулсеты: " + sets.joinToString(", ") + "."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Тул ассистента: локальная функция, которую агент (LLM) может вызвать по
|
||||
* имени. Результат возвращается строкой и подставляется в историю как
|
||||
* tool-сообщение (роль [ChatRole.TOOL]).
|
||||
*
|
||||
* Добавление нового тула: реализовать [AgentTool], добавить в список
|
||||
* [AgentTools.ALL]. Агент узнаёт о тулах из системного промпта (описание
|
||||
* [describe]) — никакой магии.
|
||||
*/
|
||||
interface AgentTool {
|
||||
/** Имя тула (как агент его называет, например `get_current_time`). */
|
||||
val name: String
|
||||
|
||||
/** Короткое описание для системного промпта (что делает, что возвращает). */
|
||||
val description: String
|
||||
|
||||
/** Вызвать тул. [arguments] — JSON-строка (пустая, если аргументов нет). */
|
||||
fun invoke(arguments: String): String
|
||||
}
|
||||
|
||||
/** Реестр тулов ассистента. */
|
||||
object AgentTools {
|
||||
val ALL: List<AgentTool> = listOf(TimeTool)
|
||||
|
||||
/** Описания для системного промпта: «Тул: name — description». */
|
||||
fun describeAll(): String = ALL.joinToString("\n") { "Тул: ${it.name} — ${it.description}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Тул времени: возвращает текущие дату, время и часовой пояс в ISO 8601
|
||||
* (например `2026-08-22T03:14:05+03:00[Europe/Moscow]`). Локально на
|
||||
* телефоне — LLM не нужна, время точное.
|
||||
*/
|
||||
object TimeTool : AgentTool {
|
||||
override val name = "get_current_time"
|
||||
override val description =
|
||||
"Текущие дата и время с часовым поясом в формате ISO 8601. Аргументы не нужны (пустой JSON {})."
|
||||
|
||||
private val formatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
|
||||
|
||||
override fun invoke(arguments: String): String {
|
||||
val now = Instant.now()
|
||||
val zone = ZoneId.systemDefault()
|
||||
return formatter.format(now.atZone(zone))
|
||||
}
|
||||
}
|
||||
@@ -7,31 +7,144 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
|
||||
* история накапливается в сессии. Синхронный вызов из одной корутины
|
||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||
*
|
||||
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
||||
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
|
||||
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
|
||||
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
|
||||
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
|
||||
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
||||
* затем делает повторный вызов LLM. Невидимые системные тулы
|
||||
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
||||
* пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
|
||||
* возвращается пользователю как финальный ответ.
|
||||
*/
|
||||
class Assistant(
|
||||
private val chatDao: ChatDao,
|
||||
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
||||
private val chat: suspend (history: List<ChatMessage>, tools: List<ToolSpec>, system: String?) -> LlmResult,
|
||||
private val toolkit: AgentToolkit? = null,
|
||||
private val tools: List<AgentTool> = AgentTools.ALL,
|
||||
private val nativeTools: Boolean = false,
|
||||
private val timeoutMs: Long = 30_000,
|
||||
) {
|
||||
/**
|
||||
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
|
||||
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
|
||||
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
||||
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
|
||||
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
|
||||
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
|
||||
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
|
||||
*/
|
||||
suspend fun process(text: String): String {
|
||||
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент")
|
||||
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession(randomTitle())
|
||||
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
||||
return processInSession(sessionId)
|
||||
}
|
||||
|
||||
/** Обработать фразу в КОНКРЕТНОЙ сессии [sessionId] (переключение сессий). */
|
||||
suspend fun processInSession(sessionId: Long, text: String): String {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
|
||||
// Сессия безымянная (создана кнопкой «новая сессия») — имя из первого сообщения.
|
||||
val s = chatDao.getSession(sessionId)
|
||||
if (s != null && s.title.isBlank()) {
|
||||
val t = text.trim().take(30)
|
||||
chatDao.renameSession(sessionId, if (t.isEmpty()) "беседка" else t)
|
||||
}
|
||||
return processInSession(sessionId)
|
||||
}
|
||||
|
||||
private suspend fun processInSession(sessionId: Long): String {
|
||||
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
||||
val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) }
|
||||
?: return "Ассистент не ответил (таймаут)"
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
chatDao.touchSession(sessionId)
|
||||
return answer
|
||||
var result = callLlm(history, sessionId)
|
||||
var lastToolResult: String? = null
|
||||
var visibleCalls = 0
|
||||
var llmCalls = 1
|
||||
// Цикл тулов: до [MAX_TOOL_CALLS] видимых вызовов подряд (защита от
|
||||
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
|
||||
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
|
||||
while (true) {
|
||||
val call = result.toolCalls.firstOrNull()
|
||||
?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
|
||||
val toolName = call.name
|
||||
if (toolkit == null) {
|
||||
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
|
||||
val tool = tools.firstOrNull { it.name == toolName }
|
||||
if (tool == null) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
|
||||
return "Тул $toolName не найден"
|
||||
}
|
||||
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, lastToolResult)
|
||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
continue
|
||||
}
|
||||
when (val dispatch = toolkit.dispatch(sessionId, toolName, call.arguments)) {
|
||||
is AgentToolkit.Dispatch.Run -> {
|
||||
lastToolResult = dispatch.result
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, dispatch.result)
|
||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
}
|
||||
|
||||
is AgentToolkit.Dispatch.Silent -> {
|
||||
// Невидимая системная тула (enable/disable_toolset): вызов не
|
||||
// пишется в историю, промпт пересобирается на следующем callLlm
|
||||
// (переход в состояние 3 — «Активные тулсеты»).
|
||||
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
|
||||
if (llmCalls++ >= MAX_LLM_CALLS) break
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// LLM зациклилась на тул-вызовах — отдаём последний результат тула как
|
||||
// ответ, чтобы пользователь получил данные, а не сырой маркер.
|
||||
val stillTool = result.toolCalls.firstOrNull()
|
||||
return if (stillTool != null && lastToolResult != null) {
|
||||
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
|
||||
} else {
|
||||
result.text
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
|
||||
val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
|
||||
val specs = if (toolkit != null) {
|
||||
toolkit.toolSpecs(sessionId)
|
||||
} else {
|
||||
tools.map { ToolSpec(name = it.name, description = it.description) }
|
||||
}
|
||||
val result = withTimeoutOrNull(timeoutMs) { chat(history, specs, system) }
|
||||
?: return LlmResult("Ассистент не ответил (таймаут)")
|
||||
if (result.toolCalls.isEmpty()) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
|
||||
chatDao.touchSession(sessionId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
|
||||
private fun legacySystemPrompt(): String {
|
||||
val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски."
|
||||
if (tools.isEmpty()) return base
|
||||
return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
// TODO(персона): промпт выберет пользователь
|
||||
const val SYSTEM_PROMPT =
|
||||
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
|
||||
/** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */
|
||||
const val MAX_TOOL_CALLS = 3
|
||||
|
||||
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
|
||||
const val MAX_LLM_CALLS = 8
|
||||
|
||||
/** Случайное имя сессии (UUID-подобное, 8 символов). */
|
||||
private fun randomTitle(): String =
|
||||
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
||||
|
||||
private fun logTool(sessionId: Long, toolName: String, result: String) {
|
||||
pw.binom.viewmate.core.log("assistant", "тул $toolName (сессия $sessionId) → $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,25 @@ class ChatDao(private val db: ChatDb) {
|
||||
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
|
||||
}
|
||||
|
||||
/** Сессия по id, или null, если не существует. */
|
||||
fun getSession(id: Long): Session? {
|
||||
val cursor = db.readableDatabase.rawQuery(
|
||||
"SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?",
|
||||
arrayOf(id.toString()),
|
||||
)
|
||||
return cursor.use { c -> if (c.moveToFirst()) c.toSession() else null }
|
||||
}
|
||||
|
||||
/** Сброс сессии: удалить все сообщения, сессия остаётся пустой. */
|
||||
fun clearSession(id: Long) {
|
||||
db.writableDatabase.delete(
|
||||
ChatDb.TABLE_MESSAGES,
|
||||
"session_id = ?",
|
||||
arrayOf(id.toString()),
|
||||
)
|
||||
touchSession(id)
|
||||
}
|
||||
|
||||
fun renameSession(id: Long, title: String) {
|
||||
db.writableDatabase.update(
|
||||
ChatDb.TABLE_SESSIONS,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
|
||||
/**
|
||||
* [MediaSource] поверх Jellyfin: search/item через [JellyfinClient].
|
||||
* [userId] — кэш приложения (PhoneApp.userId, подтягивается при старте),
|
||||
* передан провайдером: если к моменту вызова ещё не готов — [MediaSourceException]
|
||||
* (исполнитель покажет модели «Поиск недоступен: ...», а не пахнет пустым результатом).
|
||||
*/
|
||||
class JellyfinMediaSource(
|
||||
private val jellyfin: JellyfinClient,
|
||||
private val userId: () -> String,
|
||||
) : MediaSource {
|
||||
|
||||
override suspend fun search(query: String, limit: Int): List<MediaItem> {
|
||||
val user = requireUserAvailable()
|
||||
return jellyfin.search(user, query).take(limit).map { jellyfinToMediaItem(it) }
|
||||
}
|
||||
|
||||
override suspend fun item(itemId: String): MediaItem? {
|
||||
val user = requireUserAvailable()
|
||||
return jellyfin.item(user, itemId)?.let { jellyfinToMediaItem(it) }
|
||||
}
|
||||
|
||||
private fun requireUserAvailable(): String =
|
||||
userId().trim().ifBlank { throw MediaSourceException("нет пользователя Jellyfin (userId пуст)") }
|
||||
}
|
||||
|
||||
/** Модель Jellyfin → тул-элемент (JVM, тестируемо: поля, «постер есть», длительность в секундах). */
|
||||
fun jellyfinToMediaItem(item: JellyfinItem): MediaItem = MediaItem(
|
||||
id = item.Id,
|
||||
name = item.Name?.trim()?.takeIf { it.isNotEmpty() } ?: item.Id,
|
||||
type = item.Type,
|
||||
year = item.ProductionYear,
|
||||
overview = item.Overview,
|
||||
durationSeconds = item.RunTimeTicks?.let { it / 10_000_000 },
|
||||
hasPoster = item.ImageTags?.containsKey("Primary") == true,
|
||||
)
|
||||
@@ -1,64 +1,251 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
|
||||
import ai.koog.prompt.dsl.prompt
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
|
||||
import ai.koog.prompt.llm.LLMCapability
|
||||
import ai.koog.prompt.llm.LLModel
|
||||
import ai.koog.prompt.llm.LLMProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import pw.binom.viewmate.phone.log
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
|
||||
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
|
||||
* LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
|
||||
* → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
|
||||
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
|
||||
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
|
||||
*
|
||||
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
|
||||
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
|
||||
*
|
||||
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
|
||||
* Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
|
||||
* («Ассистент недоступен: …»), сам клиент не падает тихо.
|
||||
*/
|
||||
class LlmClient(
|
||||
interface LlmClient {
|
||||
/**
|
||||
* [messages] — история диалога (последнее сообщение — текущее обращение),
|
||||
* [system] — системный промпт тулкита/настроек или null (не обязателен).
|
||||
*/
|
||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
|
||||
|
||||
/**
|
||||
* Чат с тул-каналом: [tools] — схемы доступных тул ([ToolSpec]).
|
||||
* Локальный клиент регистрирует их в движке (нативный LiteRT-канал),
|
||||
* удалённый игнорирует список (тулы описывает текстовый протокол в промпте).
|
||||
* Результат [LlmResult] несёт И текст, И структурированные вызовы
|
||||
* [LlmToolCall] — клиент нормализует источник (нативный / текстовый
|
||||
* маркер), [Assistant] потребляет оба бэкенда одинаково.
|
||||
*/
|
||||
suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec> = emptyList(),
|
||||
system: String? = null,
|
||||
): LlmResult = LlmResult(text = chat(messages, system))
|
||||
}
|
||||
|
||||
/**
|
||||
* Нейтральная схема тула для [LlmClient.chatWithTools]: имя, описание и
|
||||
* аргументы (та же нотация, что у [ToolSignature]: «query», «seek_ms?» —
|
||||
* «?» = опциональный).
|
||||
*/
|
||||
data class ToolSpec(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val args: List<String> = emptyList(),
|
||||
) {
|
||||
/** Обязательные аргументы (без маркера «?»). */
|
||||
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
|
||||
|
||||
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
|
||||
fun argNames(): List<String> = args.map { it.substringBefore('?') }
|
||||
}
|
||||
|
||||
/** Вызов тула, декларированный моделью: имя + распарсенные именованные аргументы. */
|
||||
data class LlmToolCall(
|
||||
val name: String,
|
||||
val arguments: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Ответ [LlmClient.chatWithTools]: текст + декларированные вызовы тул (могут быть пусты). */
|
||||
data class LlmResult(
|
||||
val text: String,
|
||||
val toolCalls: List<LlmToolCall> = emptyList(),
|
||||
)
|
||||
|
||||
// ---------- Текстовый протокол вызова тула (RemoteLlmClient) ----------
|
||||
|
||||
/**
|
||||
* Строка вызова тула в текстовом протоколе: `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
|
||||
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
|
||||
*/
|
||||
val TOOL_CALL_RE =
|
||||
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
||||
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
||||
* [expected] — имена аргументов схемы ([ToolSpec.argNames]).
|
||||
*/
|
||||
fun parseToolCallArgs(raw: String?, expected: List<String>): Map<String, String> {
|
||||
val s = raw?.trim()
|
||||
if (s.isNullOrEmpty()) return emptyMap()
|
||||
val required = expected.filterNot { it.endsWith("?") }.distinct()
|
||||
val out = LinkedHashMap<String, String>()
|
||||
var bareIndex = 0
|
||||
for (field in s.split(Regex("[,&]"))) {
|
||||
val f = field.trim()
|
||||
if (f.isEmpty()) continue
|
||||
val eq = f.indexOf('=')
|
||||
if (eq > 0) {
|
||||
val key = f.substring(0, eq).trim()
|
||||
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
|
||||
} else if (bareIndex < required.size) {
|
||||
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
|
||||
out[required[bareIndex++]] = f
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Лог длинного текста (тела запроса, промпта, ответа) в канал [log]
|
||||
* кусками ≤3500 символов — лимит записи logcat ~4096, длиннее обрезалось бы.
|
||||
*/
|
||||
internal fun logChunked(tag: String, label: String, text: String) {
|
||||
if (text.length <= 3500) {
|
||||
log(tag, "$label: $text")
|
||||
return
|
||||
}
|
||||
val size = 3500
|
||||
val total = (text.length + size - 1) / size
|
||||
var from = 0
|
||||
var i = 0
|
||||
while (from < text.length && i < total) {
|
||||
val to = minOf(from + size, text.length)
|
||||
log(tag, "$label [${i + 1}/$total]: ${text.substring(from, to)}")
|
||||
from = to
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
|
||||
* https://llm.binom.pw/v1/chat/completions.
|
||||
*
|
||||
* Koog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
|
||||
* content на длинных историях (после tool-вызовов) — см. заметку
|
||||
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
|
||||
* модель отвечает стабильно, тул-цикл работает.
|
||||
*
|
||||
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
|
||||
* [http] инжектируется (дефолт — свой клиент с таймаутами).
|
||||
*/
|
||||
class RemoteLlmClient(
|
||||
private val apiKey: String,
|
||||
private val baseUrl: String = "https://llm.binom.pw",
|
||||
private val model: String, // модель укажет пользователь
|
||||
) {
|
||||
private val model: String,
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
) : LlmClient {
|
||||
|
||||
private val client: OpenAILLMClient = OpenAILLMClient(
|
||||
apiKey = apiKey,
|
||||
settings = OpenAIClientSettings(baseUrl = baseUrl),
|
||||
httpClientFactory = OkHttpKoogHttpClient.Factory(),
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@Serializable
|
||||
private data class ChatMessageDto(
|
||||
val role: String,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
private val llmModel: LLModel = LLModel(
|
||||
provider = LLMProvider.OpenAI,
|
||||
id = model,
|
||||
capabilities = listOf(
|
||||
LLMCapability.Completion,
|
||||
LLMCapability.OpenAIEndpoint.Completions,
|
||||
),
|
||||
@Serializable
|
||||
private data class ChatRequest(
|
||||
val model: String,
|
||||
val messages: List<ChatMessageDto>,
|
||||
val max_tokens: Int = 1024,
|
||||
val temperature: Double = 0.3,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ChatResponse(
|
||||
val choices: List<Choice> = emptyList(),
|
||||
val error: ApiError? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class Choice(val message: Message = Message())
|
||||
@Serializable
|
||||
data class Message(val content: String? = null)
|
||||
@Serializable
|
||||
data class ApiError(val message: String? = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
|
||||
* System-сообщение, если задано, добавляется первым.
|
||||
* System-сообщение, если задано, добавляется первым. TOOL-сообщения —
|
||||
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
|
||||
*/
|
||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
|
||||
val prompt = prompt("assistant-chat") {
|
||||
if (system != null) {
|
||||
this.system(system)
|
||||
}
|
||||
for (msg in messages) {
|
||||
when (msg.role) {
|
||||
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
|
||||
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
|
||||
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
|
||||
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
|
||||
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
|
||||
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
|
||||
ChatRole.TOOL -> Unit
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
val dto = ArrayList<ChatMessageDto>()
|
||||
if (system != null) dto += ChatMessageDto("system", system)
|
||||
for (msg in messages) {
|
||||
when (msg.role) {
|
||||
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
|
||||
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
|
||||
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
|
||||
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
|
||||
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return client.execute(prompt, llmModel).textContent()
|
||||
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
|
||||
|
||||
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
|
||||
log("llm", "remote: запрос: модель=$model, сообщений=${dto.size}")
|
||||
logChunked("llm", "remote: тело запроса", body)
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/v1/chat/completions")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.header("Content-Type", "application/json")
|
||||
.post(body.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
http.newCall(request).execute().use { resp ->
|
||||
val raw = resp.body?.string().orEmpty()
|
||||
if (!resp.isSuccessful) {
|
||||
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
||||
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
||||
}
|
||||
val reply = runCatching {
|
||||
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
||||
}.getOrNull()
|
||||
val text = reply?.trim() ?: ""
|
||||
logChunked("llm", "remote: ответ", text.ifEmpty { "(пусто)" })
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстовый протокол: тулы описываются в системном промпте, модель отвечает
|
||||
* строкой `TOOL_CALL: имя|арг1=знач1` — маркер разбираем здесь и
|
||||
* нормализуем в [LlmToolCall] (схема аргументов — из [tools], «голое»
|
||||
* значение прилетает первому обязательному аргументу).
|
||||
*/
|
||||
override suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec>,
|
||||
system: String?,
|
||||
): LlmResult {
|
||||
val text = chat(messages, system)
|
||||
val m = TOOL_CALL_RE.find(text) ?: return LlmResult(text)
|
||||
val name = m.groupValues[1].lowercase()
|
||||
val argsRaw = m.groupValues[2].trim().ifBlank { null }
|
||||
val expected = tools.firstOrNull { it.name == name }?.argNames() ?: emptyList()
|
||||
val args = parseToolCallArgs(argsRaw, expected)
|
||||
log("llm", "remote: toolCall: $name (сырое='${argsRaw.orEmpty()}') → $args")
|
||||
return LlmResult(text, listOf(LlmToolCall(name, args)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ object LlmDebug {
|
||||
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
|
||||
return
|
||||
}
|
||||
val client = LlmClient(apiKey = apiKey, model = model)
|
||||
val client = RemoteLlmClient(apiKey = apiKey, model = model)
|
||||
try {
|
||||
val reply = runBlockingSafe {
|
||||
client.chat(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Персистентный выбор модели LLM ассистента ([LlmModelChoice]) —
|
||||
* SharedPreferences «llm-choice». Дефолт (пусто/неизвестно) — [LlmModelChoice.REMOTE].
|
||||
*
|
||||
* Чистая логика парсинга — [LlmModelChoice.parse] (JVM-тестируемая);
|
||||
* этот класс только обёртка над SharedPreferences.
|
||||
*/
|
||||
class LlmPrefs(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun current(): LlmModelChoice = LlmModelChoice.parse(prefs.getString(KEY_MODEL_CHOICE, null))
|
||||
|
||||
fun set(choice: LlmModelChoice) {
|
||||
prefs.edit().putString(KEY_MODEL_CHOICE, choice.name).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "llm-choice"
|
||||
const val KEY_MODEL_CHOICE = "model_choice"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import pw.binom.litert.LiteBackend
|
||||
import pw.binom.litert.LiteConfig
|
||||
import pw.binom.litert.LiteConversation
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteExperimental
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.litert.LiteMessage
|
||||
import pw.binom.litert.LiteTool
|
||||
import pw.binom.litert.LiteToolCall
|
||||
import pw.binom.litert.google.googleLiteLlm
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message)
|
||||
|
||||
/** Обрезает сообщение до 200 символов, оставляя читаемые (ASCII + кириллица). */
|
||||
private fun sanitize(msg: String?): String {
|
||||
if (msg == null) return "null"
|
||||
val sb = StringBuilder()
|
||||
for (ch in msg.take(200)) {
|
||||
val c = ch.code
|
||||
if ((c in 32..126) || (c >= 0x410 && c <= 0x44F)) sb.append(ch)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp),
|
||||
* переключается UI-блоком в настройках экрана «Телефон»:
|
||||
* - [REMOTE] — сервер llm.binom.pw ([RemoteLlmClient]: OpenAI-совместимый HTTP);
|
||||
* - [LOCAL] — Gemma 4 E2B на телефоне ([LocalLlmClient], LiteRT-LM, GPU/CPU).
|
||||
*/
|
||||
enum class LlmModelChoice {
|
||||
REMOTE, LOCAL;
|
||||
|
||||
companion object {
|
||||
/** Значение из настроек; пустое/неизвестное имя → [REMOTE] (дефолт). */
|
||||
fun parse(name: String?): LlmModelChoice =
|
||||
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: REMOTE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemma 4 E2B (LiteRT-LM): локальный LLM на телефоне.
|
||||
* Модель — файл [FILE_NAME] в getExternalFilesDir(null) телефона;
|
||||
* докачивается внутри приложения (см. [GemmaModelDownloader]).
|
||||
* API-ноты: docs/litertlm-openassistant-notes.md в репо.
|
||||
*/
|
||||
object Gemma4E2B {
|
||||
const val FILE_NAME = "gemma-4-E2B-it.litertlm"
|
||||
const val SHA256 = "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c"
|
||||
|
||||
/** Размер файла для UI (2 588 147 712 байта). */
|
||||
const val SIZE_HUMAN = "≈2.5 ГБ"
|
||||
|
||||
/** Источники скачивания по приоритету: HuggingFace (открытый), затем зеркало. */
|
||||
val DOWNLOAD_URLS = listOf(
|
||||
"https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm",
|
||||
"https://data.vayunmathur.com/models/gemma-4-E2B-it.litertlm",
|
||||
)
|
||||
|
||||
/**
|
||||
* Конфиг движка — точь-в-точь как в референсе vayunmathur/Modern-Apps
|
||||
* (InferenceService.kt): GPU для основного и vision, CPU для аудио.
|
||||
* Ключ: cacheDir должен быть системным applicationContext.cacheDir —
|
||||
* GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров;
|
||||
* свой подкаталог в filesDir приводил к падению на delegate_kernel.cc.
|
||||
* Экспериментальный флаг: без enableSpeculativeDecoding статический
|
||||
* TopK-сэмплер на GPU требует OpenCL-драйвер, которого нет в Android
|
||||
* app-namespace («Can not find OpenCL library»); на HONOR работает
|
||||
* WebGPU-сэмплер, т.е. флаг включён.
|
||||
*/
|
||||
fun liteConfig(modelFile: File, cacheDir: File): LiteConfig = LiteConfig(
|
||||
modelPath = modelFile.absolutePath,
|
||||
// NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497)
|
||||
// в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на
|
||||
// этом HONOR гоняет весь движок (включая vision-сабаграф) на WebGPU; ранее
|
||||
// мы режут vision на CPU из-за 128 МБ-лимита буфера, но чистый текст-чат
|
||||
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому
|
||||
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
|
||||
// картинке — вернём vision на CPU точечно.
|
||||
backend = LiteBackend.GPU,
|
||||
visionBackend = LiteBackend.GPU,
|
||||
audioBackend = LiteBackend.CPU,
|
||||
cacheDir = cacheDir.absolutePath,
|
||||
experimental = LiteExperimental(enableSpeculativeDecoding = true),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Чистые вспомогательные (JVM-тестируемые) ----------
|
||||
|
||||
/** hex SHA-256 содержимого файла потоково (по 64 КБ — файл ~2.5 ГБ). */
|
||||
fun sha256HexOfFile(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val readSize = input.read(buf)
|
||||
if (readSize < 0) break
|
||||
digest.update(buf, 0, readSize)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** true, если [file] существует и его SHA-256 совпадает с [expectedHex]. */
|
||||
fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
|
||||
file.isFile && sha256HexOfFile(file).equals(expectedHex, ignoreCase = true)
|
||||
|
||||
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
|
||||
class LocalLlmPrompt(
|
||||
val systemInstruction: String?,
|
||||
val initialMessages: List<LiteMessage>,
|
||||
val incoming: LiteMessage,
|
||||
)
|
||||
|
||||
/**
|
||||
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
|
||||
* - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model];
|
||||
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
|
||||
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (консервативно: общая
|
||||
* с remote-клиентом текстовая конвенция; нативная роль LiteRole.TOOL —
|
||||
* задел на будущее, когда шаблон модели будет уметь родные тулзы);
|
||||
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
|
||||
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
|
||||
* - пустые content отбрасываются.
|
||||
*
|
||||
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL.
|
||||
* В RAW-режиме (контракт pw.binom.litert) владелец истории — потребитель:
|
||||
* каждый инференс — новый полный промпт, движок не несёт состояния между
|
||||
* вызовами; будущие механизмы чистки/сжатия истории для движка — просто
|
||||
* новый (короткий) полный промпт.
|
||||
* Если последние сообщения только модельные/история пуста — incoming = «Продолжай.»
|
||||
* (аналог fallback [RemoteLlmClient]).
|
||||
*/
|
||||
fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt {
|
||||
val systemParts = ArrayList<String>()
|
||||
system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
|
||||
val mapped = ArrayList<LiteMessage>()
|
||||
for (msg in messages) {
|
||||
val text = msg.content
|
||||
when (msg.role) {
|
||||
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
|
||||
ChatRole.USER -> if (text.isNotBlank()) mapped += LiteMessage.user(text)
|
||||
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += LiteMessage.model(text)
|
||||
ChatRole.TOOL -> if (text.isNotBlank()) {
|
||||
mapped += LiteMessage.user("Результат тула ${msg.toolCallId ?: "?"}: $text")
|
||||
}
|
||||
}
|
||||
}
|
||||
val last = messages.lastOrNull { it.content.isNotBlank() }
|
||||
val incoming: LiteMessage
|
||||
val initial: List<LiteMessage>
|
||||
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
|
||||
initial = mapped.dropLast(1)
|
||||
incoming = mapped.last()
|
||||
} else {
|
||||
initial = mapped
|
||||
incoming = LiteMessage.user("Продолжай.")
|
||||
}
|
||||
return LocalLlmPrompt(
|
||||
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
|
||||
initialMessages = initial,
|
||||
incoming = incoming,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI-JSON-дескриптор [ToolSpec] для LiteRT-LM: ПЛОСКИЙ объект
|
||||
* `{"name","description","parameters"}` — так его читает движок
|
||||
* (litertlm 0.16.1, ToolKt.tool: `json.get("name").getAsString()` на
|
||||
* верхнем уровне; OpenAI-обёртка `{"type":"function","function":{…}}`
|
||||
* падает ToolException «Failed to parse field name»). Движок передаёт
|
||||
* строку модели КАК ЕСТЬ — без репарсинга и валидации (контракт тул-слоя
|
||||
* pw.binom.litert, tag 5), поэтому JSON строим сами.
|
||||
* Все аргументы у нас строковые (текстовый протокол тулкита), `type` — «string».
|
||||
*/
|
||||
fun toolJson(spec: ToolSpec): String = buildJsonObject {
|
||||
put("name", spec.name)
|
||||
put("description", spec.description)
|
||||
putJsonObject("parameters") {
|
||||
put("type", "object")
|
||||
if (spec.argNames().isNotEmpty()) {
|
||||
putJsonObject("properties") {
|
||||
spec.args.forEach { a ->
|
||||
putJsonObject(a.substringBefore('?')) {
|
||||
put("type", "string")
|
||||
}
|
||||
}
|
||||
}
|
||||
val required = spec.requiredArgs()
|
||||
if (required.isNotEmpty()) {
|
||||
putJsonArray("required") {
|
||||
required.forEach { add(JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* Наш [ToolSpec] в виде [LiteTool] LiteRT-слоя: `describe()` — самособранное
|
||||
* OpenAPI-JSON (плоский, для LiteRT-LM) из [toolJson]. `invoke()` недостижим: в ручном режиме
|
||||
* (automaticToolCalling = false) тул-цикл ведёт [Assistant], движок не
|
||||
* исполняет тулы — если движок всё же позвал `invoke`, это баг движка,
|
||||
* падаем с понятным сообщением.
|
||||
*/
|
||||
private class SpecLiteTool(spec: ToolSpec) : LiteTool {
|
||||
private val descriptor: String = toolJson(spec)
|
||||
|
||||
override fun describe(): String = descriptor
|
||||
|
||||
override fun invoke(arguments: String): String =
|
||||
error("LiteTool.invoke: тул-цикл ведёт Assistant (automaticToolCalling = false), движок не должен исполнять тулы")
|
||||
}
|
||||
|
||||
// ---------- Скачивание модели ----------
|
||||
|
||||
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
|
||||
sealed interface LocalLlmModelState {
|
||||
/** Модель не скачана (или ещё не скачивалась). */
|
||||
object Idle : LocalLlmModelState
|
||||
|
||||
/** Скачивание в пути; [percent] — 0..99. */
|
||||
data class Downloading(val percent: Int) : LocalLlmModelState
|
||||
|
||||
/** Модель скачана и проверена (SHA-256 совпал при скачивании). */
|
||||
object Ready : LocalLlmModelState
|
||||
|
||||
/** Ошибка скачивания: [message]. */
|
||||
data class Error(val message: String) : LocalLlmModelState
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание Gemma 4 E2B ([Gemma4E2B.DOWNLOAD_URLS] по порядку):
|
||||
* OkHttp-стриминг в файл .part, прогресс в процентах через колбэк,
|
||||
* SHA-256-контроль после загрузки (несовпал — удалить и взять следующий
|
||||
* исходник), после успешного — перенос на [target]. Прогресс — из IO-потока
|
||||
* (колбэк должен быть потоковезбёчным; в PhoneApp — запись в StateFlow).
|
||||
*/
|
||||
class GemmaModelDownloader(
|
||||
private val target: File,
|
||||
private val urls: List<String> = Gemma4E2B.DOWNLOAD_URLS,
|
||||
private val expectedSha256: String = Gemma4E2B.SHA256,
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
) {
|
||||
/**
|
||||
* Скачать модель в [target]. Бросает [IOException] «Модель не скачалась…»
|
||||
* (все источники исчерпаны / контрольная сумма). [onProgressPercent] —
|
||||
* 0..99 во время скачивания (вызывается из IO-потока).
|
||||
*/
|
||||
suspend fun download(onProgressPercent: (Int) -> Unit) {
|
||||
target.parentFile?.mkdirs()
|
||||
val partial = File(target.parentFile, target.name + ".part")
|
||||
var lastError: String? = null
|
||||
for (url in urls) {
|
||||
var ok: Boolean
|
||||
try {
|
||||
ok = downloadFrom(url, partial, onProgressPercent)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
ok = false
|
||||
lastError = e.message ?: e.toString()
|
||||
}
|
||||
log("llm", "скачивание модели из $url: ${if (ok) "ок" else "не удалось ($lastError)"}")
|
||||
if (ok) {
|
||||
log("llm", "модель скачана: ${target.length()} байт, SHA-256 совпадает")
|
||||
return
|
||||
}
|
||||
partial.delete()
|
||||
}
|
||||
partial.delete()
|
||||
throw IOException("Модель не скачалась (все источники): $lastError — повторите позже")
|
||||
}
|
||||
|
||||
private suspend fun downloadFrom(
|
||||
url: String,
|
||||
partial: File,
|
||||
onProgressPercent: (Int) -> Unit,
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
http.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
log("llm", "HTTP ${response.code} с $url")
|
||||
return@withContext false
|
||||
}
|
||||
val body = response.body ?: return@withContext false
|
||||
val totalBytes = body.contentLength()
|
||||
var received = 0L
|
||||
var lastPercent = -1
|
||||
partial.outputStream().use { out ->
|
||||
body.byteStream().use { input ->
|
||||
val buf = ByteArray(256 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
out.write(buf, 0, n)
|
||||
received += n
|
||||
val percent = if (totalBytes > 0) ((received * 100) / totalBytes).toInt()
|
||||
else ((received / (4 * 1024 * 1024)) * 25).toInt().coerceAtMost(99)
|
||||
if (percent > lastPercent) {
|
||||
lastPercent = percent
|
||||
log("llm", "модель: скачано ${received / (1024 * 1024)} МБ" +
|
||||
(if (totalBytes > 0) " ($percent%)" else ""))
|
||||
onProgressPercent(percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sha256OfFileMatches(partial, expectedSha256)) {
|
||||
log("llm", "SHA-256 модели не совпал — перезаписываем")
|
||||
partial.delete()
|
||||
return@withContext false
|
||||
}
|
||||
partial.renameTo(target)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Клиент ----------
|
||||
|
||||
/**
|
||||
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве
|
||||
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md),
|
||||
* через KMP-библиотеку pw.binom.litert (универсальный контракт
|
||||
* RAW-инференса: владелец истории — потребитель).
|
||||
*
|
||||
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
|
||||
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
||||
* новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
|
||||
* (системная инструкция + вся история + входящее) и схемы тул,
|
||||
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
|
||||
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
||||
* пользователю, падение приложения исключено.
|
||||
*
|
||||
* Модель-файл: [modelFile] (getExternalFilesDir(null)); [cacheDir] —
|
||||
* кэш-каталог движка (cacheDir, не tmpDir — tmp-файлы удаляются системой).
|
||||
*/
|
||||
class LocalLlmClient(
|
||||
private val modelFile: File,
|
||||
private val cacheDir: File,
|
||||
) : LlmClient {
|
||||
|
||||
private val llmLock = Any()
|
||||
@Volatile
|
||||
private var llm: LiteLlm? = null
|
||||
|
||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
|
||||
fun modelDownloaded(): Boolean = modelFile.isFile
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
|
||||
chatWithTools(messages, emptyList(), system).text
|
||||
|
||||
/**
|
||||
* Чат с тул-каналом: [tools] регистрируются в движке нативным LiteRT-каналом
|
||||
* (JSON-схемы [toolJson] → модели; вызовы → [LlmResult.toolCalls]).
|
||||
* Ручной режим: движок тулы НЕ исполняет (automaticToolCalling = false) —
|
||||
* цикл «вызов → dispatch → результат в историю → повторный инференс»
|
||||
* ведёт [Assistant]; каждый круг — новый RAW-инференс.
|
||||
*/
|
||||
override suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec>,
|
||||
system: String?,
|
||||
): LlmResult {
|
||||
log("llm", "chat: вызов (messages=${messages.size}, tools=${tools.size}, system=${system?.length ?: 0} симв)")
|
||||
val llm = ensureLlm()
|
||||
log("llm", "chat: движок готов, строю промпт")
|
||||
val prompt = buildLocalLlmPrompt(messages, system)
|
||||
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}")
|
||||
// Полный контент, уходящий в модель: системный промпт, история, входящее, схемы тул
|
||||
logChunked("llm", "local: system", prompt.systemInstruction ?: "(нет)")
|
||||
prompt.initialMessages.forEachIndexed { i, m ->
|
||||
logChunked("llm", "local: msg[$i/${prompt.initialMessages.size}] ${m.role}", m.text)
|
||||
}
|
||||
logChunked("llm", "local: incoming", prompt.incoming.text)
|
||||
tools.forEachIndexed { i, spec ->
|
||||
log("llm", "local: tool[$i/${tools.size}] ${spec.name}: ${toolJson(spec)}")
|
||||
}
|
||||
val config = LiteConversationConfig(
|
||||
systemInstruction = prompt.systemInstruction,
|
||||
// RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
|
||||
initialMessages = prompt.initialMessages + prompt.incoming,
|
||||
tools = tools.map { SpecLiteTool(it) },
|
||||
// Ручной режим: исполняют тулы не движок, а Assistant (см. KDoc метода)
|
||||
automaticToolCalling = false,
|
||||
)
|
||||
var text = ""
|
||||
var toolCalls = emptyList<LiteToolCall>()
|
||||
var chunks = 0
|
||||
var tokens = 0
|
||||
val t0 = System.currentTimeMillis()
|
||||
var firstTokenAt = 0L
|
||||
var conversation: LiteConversation? = null
|
||||
try {
|
||||
withTimeout(120_000) {
|
||||
conversation = llm.createConversation(config)
|
||||
// Поток либы сам себя не завершает (callbackFlow живёт до отмены):
|
||||
// остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
|
||||
conversation.sendStream(prompt.incoming.text)
|
||||
.onEach { delta ->
|
||||
chunks++
|
||||
val chunkText = delta.text
|
||||
text += chunkText
|
||||
if (chunkText.isNotEmpty()) {
|
||||
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
|
||||
tokens += 1
|
||||
}
|
||||
if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls
|
||||
if (chunks <= 3 || chunks % 20 == 0) {
|
||||
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}${if (delta.toolCalls.isNotEmpty()) " [тулы: ${delta.toolCalls.map { it.name }}]" else ""}")
|
||||
}
|
||||
}
|
||||
.takeWhile { !it.isDone }
|
||||
.collect { }
|
||||
}
|
||||
val totalMs = System.currentTimeMillis() - t0
|
||||
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
|
||||
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
|
||||
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
|
||||
val normalized = toolCalls.map { call ->
|
||||
LlmToolCall(
|
||||
name = call.name,
|
||||
arguments = call.arguments.mapValues { entry -> entry.value?.toString() ?: "" },
|
||||
)
|
||||
}
|
||||
logChunked("llm", "local: ответ", text.trim().ifEmpty { "(пусто)" })
|
||||
normalized.forEach { call ->
|
||||
log("llm", "local: toolCall: ${call.name} аргументы=${call.arguments}")
|
||||
}
|
||||
return LlmResult(text.trim(), normalized)
|
||||
} catch (e: CancellationException) {
|
||||
log("llm", "chat: отменено")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
|
||||
log("llm", "chat: EXC msg=${sanitize(e.message)}")
|
||||
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
|
||||
} finally {
|
||||
// либа (RAW-путь) закрывает беседу сама, здесь — явный close для
|
||||
// прямого createConversation: каждый вызов — своя беседа
|
||||
runCatching { conversation?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
|
||||
fun close() {
|
||||
val local = synchronized(llmLock) {
|
||||
val l = llm
|
||||
llm = null
|
||||
l
|
||||
}
|
||||
runCatching { local?.close() }
|
||||
}
|
||||
|
||||
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
|
||||
private fun ensureLlm(): LiteLlm {
|
||||
val cached = llm
|
||||
if (cached != null) return cached
|
||||
return synchronized(llmLock) {
|
||||
val existing = llm
|
||||
if (existing != null) return existing
|
||||
if (!modelFile.isFile) {
|
||||
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
|
||||
}
|
||||
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
|
||||
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
|
||||
}
|
||||
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
|
||||
// скомпилированные шейдеры в cacheDir; если каталога нет — движок
|
||||
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
|
||||
// в кэш не пишет, поэтому работал и без mkdirs.
|
||||
cacheDir.mkdirs()
|
||||
// Экспериментальные флаги (speculative decoding → WebGPU-сэмплер)
|
||||
// ушли в LiteConfig (см. [Gemma4E2B.liteConfig]).
|
||||
val created = googleLiteLlm(Gemma4E2B.liteConfig(modelFile, cacheDir))
|
||||
log("llm", "локальный движок готов: ${modelFile.name}")
|
||||
llm = created
|
||||
created
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Медиа-элемент для тул: минимум, что нужно модели.
|
||||
* Не зависит от моделей lib-core — JVM, тестируемо фейками.
|
||||
*/
|
||||
data class MediaItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val type: String?,
|
||||
val year: Int?,
|
||||
val overview: String?,
|
||||
val durationSeconds: Long?,
|
||||
val hasPoster: Boolean,
|
||||
)
|
||||
|
||||
/** Источник медиа для тулсета: поиск + детали элемента. */
|
||||
interface MediaSource {
|
||||
/** Поиск по [query]: до [limit] результатов. */
|
||||
suspend fun search(query: String, limit: Int): List<MediaItem>
|
||||
/** Детали элемента по [itemId]; null — не найден. */
|
||||
suspend fun item(itemId: String): MediaItem?
|
||||
}
|
||||
|
||||
/** Ошибка источника: нет пользователя Jellyfin, сеть недоступна и т.п. */
|
||||
class MediaSourceException(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* Выполнить «включить на очках» (тул media_play): проверки + [pw.binom.viewmate.core.phone.PhoneActions.watch]
|
||||
* + звук на телефоне — уровень приложения. Возвращает человекочитаемый текст.
|
||||
*/
|
||||
fun interface MediaPlayer {
|
||||
suspend fun play(itemId: String, seekMs: Long): String
|
||||
}
|
||||
|
||||
/** Лимит поиска по умолчанию, когда модель его не указала. */
|
||||
const val MEDIA_SEARCH_DEFAULT_LIMIT = 5
|
||||
|
||||
/**
|
||||
* Определение тулсета media: сигнатуры БЕЗ префикса (механика добавляет media_).
|
||||
* Описания злые и короткие — маленькая модель (TASK-agent-tools, пункт 10).
|
||||
*/
|
||||
val MEDIA_TOOLSET = Toolset(
|
||||
name = "media",
|
||||
description = "медиа и воспроизведение: поиск, детали, запуск на очках (search/info/play)",
|
||||
tools = listOf(
|
||||
ToolSignature("search", "Поиск фильмов/сериалов по названию. Возвращает: id, название, год, тип.", args = listOf("query", "limit?")),
|
||||
ToolSignature("info", "Детали элемента по id: название, тип, год, описание, длительность, постер.", args = listOf("id")),
|
||||
ToolSignature("play", "Включить просмотр на очках: видео скачано на очки, звук — на телефон.", args = listOf("id", "seek_ms?")),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Исполнитель тул media: валидация аргументов + форматирование текста.
|
||||
* Источники: [media] (Jellyfin) и [player] («включить на очках» — уровень приложения).
|
||||
* Механика вызывает [execute] синхронно — а тулы suspend: мост через runBlocking
|
||||
* (вызов и так на Dispatchers.IO, из ассистентского цикла).
|
||||
*/
|
||||
class MediaToolExecutor(
|
||||
private val media: MediaSource,
|
||||
private val player: MediaPlayer,
|
||||
) : ToolsetExecutor {
|
||||
|
||||
private companion object {
|
||||
const val OVERVIEW_MAX_CHARS = 300
|
||||
|
||||
fun exceptionMessage(e: Throwable): String = e.message?.trim()?.takeIf { it.isNotEmpty() } ?: e.toString()
|
||||
}
|
||||
|
||||
override fun execute(toolset: String, tool: String, args: Map<String, String>): String = when (tool) {
|
||||
"media_search" -> search(args["query"], args["limit"])
|
||||
"media_info" -> info(args["id"])
|
||||
"media_play" -> play(args["id"], args["seek_ms"])
|
||||
else -> "Неизвестный тул \"$tool\". Доступны: media_search, media_info, media_play."
|
||||
}
|
||||
|
||||
/** media_search: строки «id: Название (Год) — Тип», до limit (дефолт [MEDIA_SEARCH_DEFAULT_LIMIT]). */
|
||||
private fun search(rawQuery: String?, rawLimit: String?): String {
|
||||
val query = rawQuery?.trim().orEmpty()
|
||||
if (query.isEmpty()) {
|
||||
return "media_search: нет аргумента \"query\". Схема: media_search|<query> [limit=<n>]"
|
||||
}
|
||||
val limit = rawLimit?.trim()?.toIntOrNull()?.coerceIn(1, 20) ?: MEDIA_SEARCH_DEFAULT_LIMIT
|
||||
val items: List<MediaItem> = runCatching { runBlocking { media.search(query, limit) } }
|
||||
.getOrElse { return "Поиск недоступен: ${exceptionMessage(it)}" }
|
||||
if (items.isEmpty()) return "Ничего не найдено по запросу «$query»."
|
||||
return items.joinToString("\n") { row ->
|
||||
val year = row.year?.let { " ($it)" }.orEmpty()
|
||||
val type = row.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
|
||||
"${row.id}: ${row.name}$year — $type"
|
||||
}
|
||||
}
|
||||
|
||||
/** media_info: «Название» — Тип (Год), описание (≤ [OVERVIEW_MAX_CHARS]), длительность, постер. */
|
||||
private fun info(rawId: String?): String {
|
||||
val id = rawId?.trim().orEmpty()
|
||||
if (id.isEmpty()) {
|
||||
return "media_info: нет аргумента \"id\". Схема: media_info|<id>"
|
||||
}
|
||||
val item: MediaItem? = runCatching { runBlocking { media.item(id) } }
|
||||
.getOrElse { return "Детали недоступны: ${exceptionMessage(it)}" }
|
||||
if (item == null) return "Элемент \"$id\" не найден."
|
||||
return infoText(item)
|
||||
}
|
||||
|
||||
/** media_play: id обязателен, seek_ms — опционально (целые миллисекунды); выполняем через [player]. */
|
||||
private fun play(rawId: String?, rawSeek: String?): String {
|
||||
val id = rawId?.trim().orEmpty()
|
||||
if (id.isEmpty()) {
|
||||
return "media_play: нет аргумента \"id\". Схема: media_play|<id> [seek_ms=<n>]"
|
||||
}
|
||||
val seekMs = when (val s = rawSeek?.trim().orEmpty()) {
|
||||
"" -> 0L
|
||||
else -> s.toLongOrNull()?.coerceAtLeast(0L)
|
||||
?: return "media_play: аргумент \"seek_ms\" должен быть числом (миллисекунды)."
|
||||
}
|
||||
return runCatching { runBlocking { player.play(id, seekMs) } }
|
||||
.getOrElse { "Ошибка воспроизведения: ${exceptionMessage(it)}" }
|
||||
}
|
||||
|
||||
private fun infoText(item: MediaItem): String {
|
||||
val year = item.year?.let { " ($it)" }.orEmpty()
|
||||
val type = item.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент"
|
||||
return buildList {
|
||||
add("«${item.name}» — ${type}$year")
|
||||
val overview = item.overview?.trim()
|
||||
if (!overview.isNullOrBlank()) {
|
||||
add("Описание: " + if (overview.length > OVERVIEW_MAX_CHARS) overview.take(OVERVIEW_MAX_CHARS).trimEnd() + "…" else overview)
|
||||
}
|
||||
item.durationSeconds?.takeIf { it > 0 }?.let { add("Длительность: ${durationText(it)}") }
|
||||
add("Постер: " + if (item.hasPoster) "есть" else "нет")
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
/** Секунды в человекочитаемом виде: ≥ 60 сек — минуты (с секундами в скобках). */
|
||||
private fun durationText(seconds: Long): String {
|
||||
val minutes = seconds / 60
|
||||
return if (minutes > 0) "${minutes} мин. (${seconds} сек.)" else "$seconds сек."
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,29 @@ data class ChatMessage(
|
||||
val toolCalls: List<ToolCall>,
|
||||
val attachments: List<Attachment>,
|
||||
val createdAt: Long,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Фабрика для сообщений до записи в БД (тесты, сборка промпта):
|
||||
* id/sessionId/createdAt — 0, пустые toolCalls/attachments.
|
||||
*/
|
||||
fun of(
|
||||
role: ChatRole,
|
||||
content: String,
|
||||
toolCallId: String? = null,
|
||||
toolCalls: List<ToolCall> = emptyList(),
|
||||
) = ChatMessage(
|
||||
id = 0L,
|
||||
sessionId = 0L,
|
||||
role = role,
|
||||
content = content,
|
||||
toolCallId = toolCallId,
|
||||
toolCalls = toolCalls,
|
||||
attachments = emptyList(),
|
||||
createdAt = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
|
||||
internal val agentJson: Json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Скилл = знание: «КАК пользоваться». Триггер (одна строка) + тело (markdown:
|
||||
* шаги, команды, питфоллы). Пассивный: живёт в файлах, читается по требованию
|
||||
* через tuл read_skill — содержимое в промпт НЕ попадает (ленивое чтение).
|
||||
*/
|
||||
data class Skill(
|
||||
val name: String,
|
||||
val trigger: String,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Читает скиллы из папки `files/skills/` (внутренняя files-папка приложения).
|
||||
* Формат файла:
|
||||
* ```
|
||||
* ---
|
||||
* name: media
|
||||
* description: поиск фильмов, детали, запуск/управление просмотром.
|
||||
* ---
|
||||
* тело скилла (markdown)...
|
||||
* ```
|
||||
* Без frontmatter — имя берётся из имени файла, всё содержимое = тело.
|
||||
* Нетовой папки или пустой каталог — пустой список (механика работает на
|
||||
* пустом каталоге; реальные скиллы кладутся позже).
|
||||
*/
|
||||
class SkillRepository(private val directory: File) {
|
||||
private val extensions = setOf("md", "markdown", "txt")
|
||||
|
||||
fun loadAll(): List<Skill> {
|
||||
val files = directory.listFiles { f -> f.isFile && f.extension.lowercase() in extensions }
|
||||
?: return emptyList()
|
||||
return files
|
||||
.sortedBy { it.name }
|
||||
.mapNotNull { parse(it) }
|
||||
.distinctBy { it.name }
|
||||
}
|
||||
|
||||
private fun parse(file: File): Skill? {
|
||||
val text = runCatching { file.readText() }.getOrNull() ?: return null
|
||||
val lines = text.lines()
|
||||
var name = file.nameWithoutExtension
|
||||
var trigger = ""
|
||||
var body = text.trim()
|
||||
if (lines.isNotEmpty() && lines[0].trim() == "---") {
|
||||
// Фронтматтер: блок строками key: value между первой и второй строкой ---.
|
||||
val close = lines.drop(1).indexOfFirst { it.trim() == "---" }
|
||||
if (close >= 0) {
|
||||
val fm = lines.subList(1, close + 1)
|
||||
fmValue(fm, "name")?.let { name = it }
|
||||
trigger = fmValue(fm, "description") ?: ""
|
||||
body = lines.drop(close + 2).joinToString("\n").trim()
|
||||
}
|
||||
}
|
||||
return Skill(name = name, trigger = trigger, body = body)
|
||||
}
|
||||
|
||||
/** Значение ключа из frontmatter (`key: значение`); пустое/отсутствующее — null. */
|
||||
private fun fmValue(fm: List<String>, key: String): String? =
|
||||
fm.firstOrNull { it.startsWith("$key:") }
|
||||
?.substringAfter(':')
|
||||
?.trim()
|
||||
?.ifBlank { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Индекс скиллов (имя + триггер) для промпта; полное содержимое — только
|
||||
* через [readFull] лениво (вызов read_skill). Индекс идёт в промпт; когда
|
||||
* связанный тулсет активен, свой скилл (имя = имя тулсета) из индекса УБИРАЕТСЯ.
|
||||
*/
|
||||
class SkillRegistry(skills: List<Skill>) {
|
||||
private val byName: Map<String, Skill> = skills.associateBy { it.name.lowercase() }
|
||||
|
||||
/** Все имена (стабильный порядок). */
|
||||
fun names(): List<String> = byName.values.map { it.name }
|
||||
|
||||
fun has(name: String): Boolean = byName.containsKey(name.lowercase())
|
||||
|
||||
/**
|
||||
* Строки индекса «имя — триггер» (одна строка на скилл). [excluded] —
|
||||
* имена скиллов, которых в индексе не должно быть (например, скилл
|
||||
* активного тулсета: искушение удалить, а не запретить).
|
||||
*/
|
||||
fun index(excluded: Set<String> = emptySet()): List<String> =
|
||||
byName.values
|
||||
.filter { !excluded.contains(it.name.lowercase()) }
|
||||
.sortedBy { it.name }
|
||||
.map { s -> s.trigger.takeIf { it.isNotBlank() }?.let { "${s.name} — $it" } ?: s.name }
|
||||
|
||||
/** Полное содержимое скилла (триггер + тело) — результат read_skill. */
|
||||
fun readFull(name: String): String? =
|
||||
byName[name.lowercase()]?.let { s ->
|
||||
buildString {
|
||||
append("Скилл \"").append(s.name).append("\"\n")
|
||||
if (s.trigger.isNotBlank()) append("Триггер: ").append(s.trigger).append('\n')
|
||||
append("\n")
|
||||
append(s.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Тулсет = набор тул (активные руки, «ЧТО МОЖНО»). Все тулы набора несут
|
||||
* префикс ИМЕНИ набора: media_search, media_info — префикс = маршрутизация
|
||||
* (система видит media_* → знает набор и сигнатуры).
|
||||
*
|
||||
* Аргументы сигнатуры описываются ИМЕНАМИ: «query» — обязательный,
|
||||
* «seek_ms?» — опциональный («?»). Описание тула в промпте одной строкой:
|
||||
* имя + что делает + аргументы — компактно для маленькой модели.
|
||||
*/
|
||||
data class ToolSignature(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val args: List<String> = emptyList(),
|
||||
) {
|
||||
/** Обязательные аргументы (без маркера «?»). */
|
||||
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
|
||||
|
||||
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
|
||||
fun argNames(): List<String> = args.map { it.substringBefore('?') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Набор тул: имя (одновременно префикс), описание для каталога, список тул
|
||||
* (сигнатуры БЕЗ префикса — префикс добавляется при маршрутизации).
|
||||
*/
|
||||
data class Toolset(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val tools: List<ToolSignature>,
|
||||
) {
|
||||
/** Полное имя тула (с префиксом): media + search → media_search. */
|
||||
fun toolFull(signature: ToolSignature): String = "${name}_${signature.name}"
|
||||
|
||||
/** Найти сигнатуру по полному имени (media_search → search). */
|
||||
fun signatureOf(fullName: String): ToolSignature? =
|
||||
tools.firstOrNull { toolFull(it) == fullName }
|
||||
|
||||
/** Строка каталога: «media — управление медиа и воспроизведением (search/info/play)». */
|
||||
fun catalogLine(): String = "$name — $description"
|
||||
|
||||
/** Строки описаний тул для промпта (секция «Активные тулсеты»). */
|
||||
fun toolLines(): List<String> = tools.map { s ->
|
||||
val argsPart = if (s.args.isEmpty()) "" else "|" + s.args.joinToString(",")
|
||||
"${name}_${s.name}$argsPart — ${s.description}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Исполнитель тул набора: (набор, тул-имя (с префиксом), аргументы) → строка результата. */
|
||||
fun interface ToolsetExecutor {
|
||||
fun execute(toolset: String, tool: String, args: Map<String, String>): String
|
||||
}
|
||||
|
||||
/** Результат enable_toolset. */
|
||||
sealed class EnableResult {
|
||||
/** Активен (при повторном вызове уже активен — таймаут продлён). */
|
||||
class Activated(val toolset: String, val alreadyActive: Boolean) : EnableResult()
|
||||
/** Неизвестное имя: сообщение для модели («набора X нет, доступны: ...»). */
|
||||
class Unknown(val name: String, val message: String) : EnableResult()
|
||||
}
|
||||
|
||||
/** Результат disable_toolset — всегда не ошибка (идемпотентно и прощающе). */
|
||||
sealed class DisableResult {
|
||||
class Disabled(val toolset: String) : DisableResult()
|
||||
/** Набор не был активен — информационно, не ошибка. */
|
||||
class NotActive(val message: String) : DisableResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Реестр тулсетов + активность НА СЕССИЮ (сессия → набор → deadline).
|
||||
*
|
||||
* - Не персистится: перезапуск приложения = всё сброшено.
|
||||
* - Таймаут: [DEFAULT_TIMEOUT_MS] (10 минут) с последнего вызова тулы набора;
|
||||
* enable и каждый успешный вызов [touch] продлевают.
|
||||
* - Проверка ТОЛЬКО при сборке промпта / диспаче — никаких фоновых таймеров:
|
||||
* просроченные записи чистятся лениво в [activeNames]/[isActive].
|
||||
*
|
||||
* Часы и таймаут инжектируемые — юнит-тесты не ждут реальные 10 минут.
|
||||
*/
|
||||
class ToolsetRegistry(
|
||||
val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
companion object {
|
||||
/** Дефолтный таймаут активности: 10 минут. */
|
||||
const val DEFAULT_TIMEOUT_MS = 10 * 60_000L
|
||||
}
|
||||
|
||||
private val toolsets = LinkedHashMap<String, Toolset>()
|
||||
private val executors = HashMap<String, ToolsetExecutor>()
|
||||
/** сессия → (набор → deadline, epoch-мс). Только в памяти. */
|
||||
private val active = ConcurrentHashMap<Long, ConcurrentHashMap<String, Long>>()
|
||||
|
||||
/** Зарегистрировать набор (опционально с исполнителем его тул). */
|
||||
fun register(toolset: Toolset, executor: ToolsetExecutor? = null) {
|
||||
synchronized(this) {
|
||||
toolsets[toolset.name] = toolset
|
||||
if (executor != null) executors[toolset.name] = executor
|
||||
}
|
||||
}
|
||||
|
||||
/** Имена всех наборов каталога. */
|
||||
fun names(): List<String> = synchronized(this) { toolsets.keys.toList() }
|
||||
|
||||
fun toolset(name: String): Toolset? = synchronized(this) { toolsets[name] }
|
||||
|
||||
/**
|
||||
* Префиксная маршрутизация: имя тула с префиксом набора (media_search → media).
|
||||
* Префикс = «имени_».
|
||||
*/
|
||||
fun findByPrefix(fullToolName: String): Toolset? =
|
||||
synchronized(this) {
|
||||
toolsets.entries.firstOrNull { (n, _) -> fullToolName.startsWith(n + "_") }?.value
|
||||
}
|
||||
|
||||
fun executor(name: String): ToolsetExecutor? = synchronized(this) { executors[name] }
|
||||
|
||||
/** Строки каталога: «media — ...» (по одной строке на набор). */
|
||||
fun catalogLines(): List<String> = synchronized(this) { toolsets.values.map { it.catalogLine() } }
|
||||
|
||||
/**
|
||||
* Активные наборы сессии (deadline > now). Просроченные записи чистятся
|
||||
* здесь же (ленивая проверка, нет фона). Порядок = порядок каталога.
|
||||
*/
|
||||
fun activeNames(sessionId: Long): List<String> =
|
||||
synchronized(this) {
|
||||
val session = active[sessionId] ?: return emptyList()
|
||||
val now = clock()
|
||||
val expired = session.entries.filter { it.value <= now }.map { it.key }
|
||||
expired.forEach { session.remove(it) }
|
||||
if (session.isEmpty()) active.remove(sessionId)
|
||||
toolsets.keys.filter { session.containsKey(it) }
|
||||
}
|
||||
|
||||
/** Активен ли набор в сессии (с очисткой просроченных). */
|
||||
fun isActive(sessionId: Long, name: String): Boolean = activeNames(sessionId).contains(name)
|
||||
|
||||
/** Сигнатура тула, если тул известного набора (для валидации аргументов). */
|
||||
fun signatureOf(fullToolName: String): ToolSignature? =
|
||||
findByPrefix(fullToolName)?.signatureOf(fullToolName)
|
||||
|
||||
/**
|
||||
* Активировать набор в сессии (идемпотентно: повторный enable = продление
|
||||
* таймаута, не ошибка). Deadline = now + timeout.
|
||||
*/
|
||||
fun enable(sessionId: Long, name: String): EnableResult {
|
||||
val ts = toolset(name) ?: return EnableResult.Unknown(
|
||||
name = name,
|
||||
message = "Набора \"$name\" нет. " + when {
|
||||
toolsets.isNotEmpty() -> "Доступны: " + names().joinToString(", ")
|
||||
else -> "Доступных тулсетов нет."
|
||||
},
|
||||
)
|
||||
synchronized(this) {
|
||||
val session = active.computeIfAbsent(sessionId) { ConcurrentHashMap() }
|
||||
val already = session.containsKey(ts.name)
|
||||
session[ts.name] = clock() + timeoutMs
|
||||
return EnableResult.Activated(ts.name, already)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Снять набор в сессии (идемпотентно: «нет активных наборов» — не ошибка).
|
||||
*/
|
||||
fun disable(sessionId: Long, name: String): DisableResult {
|
||||
synchronized(this) {
|
||||
val session = active[sessionId]
|
||||
if (session != null && session.remove(name) != null) return DisableResult.Disabled(name)
|
||||
val anyActive = if (session != null && session.isNotEmpty()) {
|
||||
val now = clock()
|
||||
session.entries.removeAll { it.value <= now }
|
||||
session.isNotEmpty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val msg = if (anyActive) "Тулсет \"$name\" не активен." else "Нет активных тулсетов."
|
||||
return DisableResult.NotActive(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Продлить таймаут (вызывается после успешного выполнения тулы набора). */
|
||||
fun touch(sessionId: Long, name: String) {
|
||||
synchronized(this) {
|
||||
val session = active[sessionId] ?: return
|
||||
if (session.containsKey(name)) session[name] = clock() + timeoutMs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
/** Распознавание одной фразы (16 кГц mono PCM) в текст. */
|
||||
interface PhraseRecognizer {
|
||||
fun recognize(samples: FloatArray): String
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import pw.binom.asr.qwen3.Qwen3Config
|
||||
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||
|
||||
/** Офлайн-распознавание: asr-qwen3 (sherpa-onnx + Qwen3-ASR-0.6B int8, CPU). */
|
||||
class Qwen3AsrStt(
|
||||
convFrontendPath: String,
|
||||
encoderPath: String,
|
||||
decoderPath: String,
|
||||
tokenizerDir: String,
|
||||
numThreads: Int = 4,
|
||||
hotwords: String = "",
|
||||
) : PhraseRecognizer {
|
||||
/** Тяжёлая нативка создаётся один раз; recognize() — single-shot (стрим на вызов). */
|
||||
private val recognizer = createQwen3Sherpa(
|
||||
Qwen3Config(
|
||||
convFrontendPath = convFrontendPath,
|
||||
encoderPath = encoderPath,
|
||||
decoderPath = decoderPath,
|
||||
tokenizerDir = tokenizerDir,
|
||||
numThreads = numThreads,
|
||||
hotwords = hotwords,
|
||||
),
|
||||
)
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||
override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
|
||||
import com.k2fsa.sherpa.onnx.Vad
|
||||
import com.k2fsa.sherpa.onnx.VadModelConfig
|
||||
|
||||
/**
|
||||
* Боевой стриминговый VAD на нативной [Vad] (sherpa-onnx, Silero).
|
||||
* Те же параметры, что были в vadConfig() SttStreamer: threshold 0.5,
|
||||
* minSilence/minSpeech 0.25 с, окно 512, maxSpeech 20 с, 16 кГц, 1 поток, CPU.
|
||||
*/
|
||||
class SherpaStreamingVad(modelPath: String) : StreamingVad {
|
||||
|
||||
private val vad: Vad = Vad(
|
||||
null,
|
||||
VadModelConfig(
|
||||
sileroVadModelConfig = SileroVadModelConfig(
|
||||
model = modelPath,
|
||||
threshold = 0.5f,
|
||||
minSilenceDuration = 0.25f,
|
||||
minSpeechDuration = 0.25f,
|
||||
windowSize = 512,
|
||||
maxSpeechDuration = 20f,
|
||||
),
|
||||
sampleRate = 16000,
|
||||
numThreads = 1,
|
||||
provider = "cpu",
|
||||
),
|
||||
)
|
||||
|
||||
override val windowSizeSamples: Int = 512
|
||||
|
||||
override fun acceptWindow(window: FloatArray) {
|
||||
vad.acceptWaveform(window)
|
||||
}
|
||||
|
||||
override fun popSegment(): VadSegment? {
|
||||
if (vad.empty()) return null
|
||||
val s = vad.front()
|
||||
val out = VadSegment(s.start, s.samples)
|
||||
vad.pop()
|
||||
return out
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
vad.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { vad.release() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
/** Сегмент речи: [startSample, startSample+samples.size) в сэмплах 16 кГц. */
|
||||
class VadSegment(val startSample: Int, val samples: FloatArray)
|
||||
|
||||
/**
|
||||
* Стриминговый VAD: подача окнами по windowSizeSamples → готовые сегменты.
|
||||
* Реализации: [SherpaStreamingVad] (боевая, Android), SileroStreamingVad (тесты, JVM).
|
||||
*/
|
||||
interface StreamingVad {
|
||||
val windowSizeSamples: Int
|
||||
/** Подать окно; готовые сегменты (если появились) забираются popSegment(). */
|
||||
fun acceptWindow(window: FloatArray)
|
||||
/** Последний готовый сегмент (копию caller должен делать сам, если нужно удерживать). */
|
||||
fun popSegment(): VadSegment?
|
||||
/** Закрыть текущий незавершённый сегмент (если есть). */
|
||||
fun flush()
|
||||
fun close()
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
|
||||
object SttDebug {
|
||||
|
||||
private const val TAG = "stt"
|
||||
|
||||
/**
|
||||
* Распознаёт [wavPath] по фразам (VAD + Whisper).
|
||||
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
|
||||
@@ -23,16 +20,24 @@ object SttDebug {
|
||||
// через: adb push /data/local/tmp/... + run-as pkg cp
|
||||
val modelsDir = File(context.filesDir, "models")
|
||||
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
|
||||
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath
|
||||
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath
|
||||
val tokens = File(modelsDir, "small-tokens.txt").absolutePath
|
||||
val convFrontend = File(modelsDir, "conv_frontend.onnx").absolutePath
|
||||
val encoder = File(modelsDir, "encoder.int8.onnx").absolutePath
|
||||
val decoder = File(modelsDir, "decoder.int8.onnx").absolutePath
|
||||
val tokens = File(modelsDir, "tokenizer").absolutePath
|
||||
|
||||
log("[stt] создаём VAD...")
|
||||
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
|
||||
log("[stt] фраз (VAD): ${segments.size}")
|
||||
|
||||
log("[stt] создаём Whisper-small (int8, CPU)...")
|
||||
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4)
|
||||
log("[stt] создаём Qwen3-ASR-0.6B (int8, CPU)...")
|
||||
val stt = Qwen3AsrStt(
|
||||
convFrontendPath = convFrontend,
|
||||
encoderPath = encoder,
|
||||
decoderPath = decoder,
|
||||
tokenizerDir = tokens,
|
||||
numThreads = 4,
|
||||
hotwords = "тулсет,тулы,умеешь",
|
||||
)
|
||||
val parts = ArrayList<String>()
|
||||
for ((i, seg) in segments.withIndex()) {
|
||||
val startMs = seg.first / 16
|
||||
@@ -46,14 +51,14 @@ object SttDebug {
|
||||
log("[stt] ВЕСЬ ТЕКСТ: $all")
|
||||
all
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "[stt] ошибка: ${t.message}", t)
|
||||
log("[stt] ошибка: ${t.message}")
|
||||
t.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(msg: String) {
|
||||
Log.i(TAG, msg)
|
||||
pw.binom.viewmate.phone.log("stt", msg)
|
||||
println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,47 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
|
||||
import com.k2fsa.sherpa.onnx.Vad
|
||||
import com.k2fsa.sherpa.onnx.VadModelConfig
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||
val n = pcm.size / 2
|
||||
val out = FloatArray(n)
|
||||
var i = 0
|
||||
for (j in 0 until n) {
|
||||
val lo = pcm[i].toInt() and 0xff
|
||||
val hi = pcm[i + 1].toInt() and 0xff
|
||||
i += 2
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
out[j] = (s / 32768f).coerceIn(-1f, 1f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
import pw.binom.viewmate.core.stt.splitPhrases
|
||||
import pw.binom.viewmate.core.stt.trimEdges
|
||||
import pw.binom.viewmate.phone.log
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
|
||||
* Whisper (в отдельном потоке) → колбэк [onPhrase].
|
||||
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||
* [PhraseRecognizer] с предварительным обрезанием краевой тишины ([trimEdges]).
|
||||
*
|
||||
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
|
||||
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
|
||||
* идёт в однопоточном executor-е (не блокирует приём аудио).
|
||||
*
|
||||
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s]
|
||||
* (автo-отмена, вызывается один раз за сессию).
|
||||
* Тишина [SILENCE_TIMEOUT_MS] (с момента первого [accept]) → автo-финиш:
|
||||
* накопленный буфер распознаётся (как [finish]) и результат отдаётся в
|
||||
* [onAutoFinished], затем сессия сбрасывается и вызывается [onSilence30s]
|
||||
* (автo-отмена, один раз за сессию).
|
||||
*/
|
||||
class SttStreamer(
|
||||
private val stt: WhisperStt,
|
||||
private val vadModelPath: String,
|
||||
private val onPhrase: (phrase: String, full: String) -> Unit,
|
||||
private val stt: PhraseRecognizer,
|
||||
private val onSilence30s: () -> Unit,
|
||||
/** Полный текст сессии, распознанный автo-финишем по тишине (см. [onSilence30s]). */
|
||||
private val onAutoFinished: (full: String) -> Unit,
|
||||
private val dumpPcm: File? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val SILENCE_TIMEOUT_MS = 30_000L
|
||||
private const val WINDOW_SIZE = 512
|
||||
|
||||
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
|
||||
/** Максимум PCM для распознавания: 120 с (дольше — последние 120 с). */
|
||||
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 120
|
||||
}
|
||||
|
||||
private val lock = java.util.concurrent.locks.ReentrantLock()
|
||||
private val vad: Vad = Vad(null, vadConfig())
|
||||
private val executor = Executors.newSingleThreadExecutor { r ->
|
||||
Thread(r, "stt-recognize").apply { isDaemon = true }
|
||||
}
|
||||
@@ -64,22 +58,40 @@ class SttStreamer(
|
||||
@Volatile
|
||||
private var deadlineMs = 0L
|
||||
|
||||
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
||||
private var pending = FloatArray(0)
|
||||
/**
|
||||
* Debug-дампа принятых PCM: сырые s16le (16 кГц, mono), без WAV-заголовка —
|
||||
* формат помечен в логе. Сбой открытия не ломает распознавание.
|
||||
*/
|
||||
private val dumpOut: FileOutputStream? = dumpPcm?.let { file ->
|
||||
runCatching {
|
||||
FileOutputStream(file, true).also {
|
||||
log("stt", "PCM-дампа: ${file.absolutePath} (16 кГц, s16, mono)")
|
||||
}
|
||||
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
|
||||
}
|
||||
|
||||
/** Весь PCM сессии (FloatArray @16 кГц): [finish] прогоняет его через распознаватель одним вызовом. */
|
||||
private var sessionPcm: FloatArray = FloatArray(0)
|
||||
|
||||
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
|
||||
private var lastFuture: Future<*>? = null
|
||||
private var lastFuture: java.util.concurrent.Future<*>? = null
|
||||
|
||||
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
|
||||
while (true) {
|
||||
val deadline = deadlineMs
|
||||
if (deadline > 0 && System.currentTimeMillis() > deadline) {
|
||||
lock.withLock {
|
||||
if (deadlineMs == deadline) {
|
||||
deadlineMs = 0
|
||||
resetLocked()
|
||||
onSilence30s()
|
||||
}
|
||||
// Снимаем срок и освобождаем lock до распознавания — watchdog не держит
|
||||
// lock при вызове finish (иначе блокировка-в-блокировке c accept).
|
||||
val expired = lock.withLock {
|
||||
if (deadlineMs != deadline) return@withLock false
|
||||
deadlineMs = 0
|
||||
true
|
||||
}
|
||||
if (expired) {
|
||||
val fullText = finish()
|
||||
if (fullText.isNotBlank()) onAutoFinished(fullText)
|
||||
lock.withLock { resetLocked() }
|
||||
onSilence30s()
|
||||
}
|
||||
}
|
||||
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
|
||||
@@ -91,110 +103,92 @@ class SttStreamer(
|
||||
lock.withLock {
|
||||
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
||||
val floats = s16leToFloat(pcm)
|
||||
var i = 0
|
||||
|
||||
if (pending.isNotEmpty()) {
|
||||
val need = WINDOW_SIZE - pending.size
|
||||
val take = minOf(need, floats.size)
|
||||
val combined = pending + floats.copyOfRange(0, take)
|
||||
i += take
|
||||
if (combined.size == WINDOW_SIZE) {
|
||||
feedWindow(combined)
|
||||
pending = FloatArray(0)
|
||||
} else {
|
||||
pending = combined
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
while (i + WINDOW_SIZE <= floats.size) {
|
||||
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE))
|
||||
i += WINDOW_SIZE
|
||||
}
|
||||
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
|
||||
sessionPcm = sessionPcm + floats
|
||||
}
|
||||
val out = dumpOut
|
||||
if (out != null) {
|
||||
runCatching { out.write(pcm) }
|
||||
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
|
||||
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после
|
||||
* вызова нужно [reset] (состояние сессии снимает вызывающий).
|
||||
* Завершить сессию (клик): распознать весь буфер сессии одним вызовом
|
||||
* (хвост [MAX_FULL_BUFFER_SAMPLES], если длиннее) с обрезанием краевой тишины
|
||||
* — [full] перезаписывается результатом. Вернуть полный текст.
|
||||
* Буферы НЕ сбрасывает — после вызова нужен [reset] (состояние сессии
|
||||
* снимает вызывающий).
|
||||
*/
|
||||
fun finish(): String {
|
||||
lock.withLock {
|
||||
vad.flush()
|
||||
while (!vad.empty()) {
|
||||
val seg = vad.front()
|
||||
recognizeSegment(seg.samples)
|
||||
vad.pop()
|
||||
}
|
||||
val last = lastFuture
|
||||
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
|
||||
recognizeFullBufferLocked()
|
||||
return full
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Распознать весь буфер сессии [sessionPcm]: целиком, если ≤
|
||||
* [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] сэмплов
|
||||
* (команда перед кликом точно влезает). Перед распознаванием применяется
|
||||
* [trimEdges], затем [splitPhrases] режет буфер на фразы по паузам — каждая
|
||||
* фраза распознаётся отдельно (whisper принимает волны только ≤ 30 с),
|
||||
* непустые результаты склеиваются через пробел и перезаписывают [full].
|
||||
* Все фразы мусорные → [full] НЕ трогается. Вызывать с удержанным [lock].
|
||||
*/
|
||||
private fun recognizeFullBufferLocked() {
|
||||
val buffer = sessionPcm
|
||||
if (buffer.isEmpty()) return
|
||||
val base = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
|
||||
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
|
||||
} else buffer
|
||||
val chunk = trimEdges(base)
|
||||
if (chunk.isEmpty()) return
|
||||
val phrases = splitPhrases(chunk)
|
||||
if (phrases.isEmpty()) return
|
||||
val sessionId = session
|
||||
val sizes = phrases.map { "%.1fс".format(it.size / SAMPLE_RATE.toFloat()) }.joinToString(", ")
|
||||
val task = executor.submit {
|
||||
if (sessionId != session) return@submit
|
||||
runCatching {
|
||||
val parts = ArrayList<String>()
|
||||
for (ph in phrases) {
|
||||
val text = stt.recognize(ph).trim()
|
||||
if (text.isNotBlank()) parts.add(text)
|
||||
}
|
||||
if (parts.isNotEmpty()) full = parts.joinToString(" ")
|
||||
parts
|
||||
}.onSuccess { parts ->
|
||||
if (parts.isEmpty()) {
|
||||
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) — без текста (мусор, full не меняю)")
|
||||
} else {
|
||||
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) → '$full'")
|
||||
}
|
||||
}.onFailure {
|
||||
log("stt", "полный буфер: ошибка распознавания: ${it.message}")
|
||||
}
|
||||
}
|
||||
lastFuture = task
|
||||
runCatching { task.get(60, TimeUnit.SECONDS) }
|
||||
}
|
||||
|
||||
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
||||
fun reset() {
|
||||
lock.withLock { resetLocked() }
|
||||
}
|
||||
|
||||
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */
|
||||
/** Освободить ресурсы и сторожевой поток (при выключении приложения). */
|
||||
fun close() {
|
||||
lock.withLock {
|
||||
deadlineMs = 0
|
||||
runCatching { vad.release() }
|
||||
}
|
||||
lock.withLock { deadlineMs = 0 }
|
||||
runCatching { dumpOut?.close() }
|
||||
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||
executor.shutdownNow()
|
||||
watchdog.interrupt()
|
||||
}
|
||||
|
||||
private fun resetLocked() {
|
||||
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
|
||||
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
|
||||
runCatching { vad.flush() }
|
||||
while (!vad.empty()) vad.pop()
|
||||
session++
|
||||
full = ""
|
||||
pending = FloatArray(0)
|
||||
sessionPcm = FloatArray(0)
|
||||
deadlineMs = 0
|
||||
}
|
||||
|
||||
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
|
||||
private fun feedWindow(window: FloatArray) {
|
||||
vad.acceptWaveform(window)
|
||||
while (!vad.empty()) {
|
||||
val seg = vad.front()
|
||||
// Копия: массив из нативного VAD может переиспользоваться после pop.
|
||||
recognizeSegment(seg.samples.copyOf())
|
||||
vad.pop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Распознать сегмент в executor-е: обновить full и позвать onPhrase. */
|
||||
private fun recognizeSegment(samples: FloatArray) {
|
||||
val sessionId = session
|
||||
lastFuture = executor.submit {
|
||||
if (sessionId != session) return@submit
|
||||
val phrase = runCatching { stt.recognize(samples) }.getOrDefault("")
|
||||
if (phrase.isBlank()) return@submit
|
||||
val newFull = if (full.isEmpty()) phrase else "$full $phrase"
|
||||
full = newFull
|
||||
deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
||||
onPhrase(phrase, newFull)
|
||||
}
|
||||
}
|
||||
|
||||
private fun vadConfig(): VadModelConfig = VadModelConfig(
|
||||
sileroVadModelConfig = SileroVadModelConfig(
|
||||
model = vadModelPath,
|
||||
threshold = 0.5f,
|
||||
minSilenceDuration = 0.25f,
|
||||
minSpeechDuration = 0.25f,
|
||||
windowSize = WINDOW_SIZE,
|
||||
maxSpeechDuration = 20f,
|
||||
),
|
||||
sampleRate = 16000,
|
||||
numThreads = 1,
|
||||
provider = "cpu",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import com.k2fsa.sherpa.onnx.OfflineModelConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizer
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig
|
||||
|
||||
/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */
|
||||
class WhisperStt(
|
||||
private val encoderPath: String,
|
||||
private val decoderPath: String,
|
||||
private val tokensPath: String,
|
||||
private val numThreads: Int = 4,
|
||||
private val language: String = "ru",
|
||||
) {
|
||||
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||
null,
|
||||
OfflineRecognizerConfig(
|
||||
modelConfig = OfflineModelConfig(
|
||||
whisper = OfflineWhisperModelConfig(
|
||||
encoder = encoderPath,
|
||||
decoder = decoderPath,
|
||||
language = language,
|
||||
task = "transcribe",
|
||||
tailPaddings = -1,
|
||||
),
|
||||
tokens = tokensPath,
|
||||
numThreads = numThreads,
|
||||
provider = "cpu",
|
||||
modelType = "whisper",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||
fun recognize(samples: FloatArray): String {
|
||||
val stream = recognizer.createStream()
|
||||
try {
|
||||
stream.acceptWaveform(samples, 16000)
|
||||
recognizer.decode(stream)
|
||||
return recognizer.getResult(stream).text.trim()
|
||||
} finally {
|
||||
stream.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase as GlassesDownloadPhase
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress as GlassesDownloadProgress
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.DownloadPhase as PhoneDownloadPhase
|
||||
import pw.binom.viewmate.phone.DownloadProgress as PhoneDownloadProgress
|
||||
|
||||
/** Статусы зеркала, которые считаются «конвертируется прямо сейчас». */
|
||||
private val CONVERTING_STATUSES = setOf("new", "queued", "processing")
|
||||
|
||||
/** Как часто опрашиваем mirror.list, пока открыт экран (конвертация не шлёт события сама). */
|
||||
private const val REFRESH_MS = 15_000L
|
||||
|
||||
/** Строка активности для единого списка в LazyColumn. */
|
||||
private sealed interface ActivityRow {
|
||||
val key: String
|
||||
|
||||
data class Header(val text: String, override val key: String) : ActivityRow
|
||||
data class Convert(val itemId: String, val title: String, val statusLabel: String, override val key: String) : ActivityRow
|
||||
data class Download(val itemId: String, val title: String, val kind: String, val percent: Int, override val key: String) : ActivityRow
|
||||
}
|
||||
|
||||
/**
|
||||
* Экран «Активность»: всё, что сейчас конвертируется (зеркала) и скачивается
|
||||
* (звук → телефон, видео → очки). Уровень 1: без серверной доработки — по
|
||||
* конвертации спиннер (progress на сервере не живой), по скачиванию живой %.
|
||||
*/
|
||||
@Composable
|
||||
fun ActivityScreen() {
|
||||
val app = PhoneApp.instance
|
||||
val userId by app.userId.collectAsState()
|
||||
|
||||
// Конвертирующиеся зеркала: опрос mirror.list раз в REFRESH_MS (нет push от сервера).
|
||||
var converting by remember { mutableStateOf<List<MirrorJob>>(emptyList()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (isActive) {
|
||||
converting = runCatching {
|
||||
app.mirror.list(limit = 500).filter { it.status in CONVERTING_STATUSES }
|
||||
}.getOrDefault(converting)
|
||||
delay(REFRESH_MS)
|
||||
}
|
||||
}
|
||||
|
||||
// Живой прогресс скачивания: звук (телефон) — DownloadManager, видео (очки) — хаб.
|
||||
val phoneDownloads by app.downloadManager.downloads.collectAsState()
|
||||
val activePhone by app.downloadManager.activeIds.collectAsState()
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
|
||||
// Настройки/названия предметов по активным itemId (подтягиваем только недостающие).
|
||||
val activeIds = remember(converting, activePhone, glassesDownloads) {
|
||||
(converting.mapNotNull { it.itemId } + activePhone + glassesDownloads.keys.filter { it.isNotBlank() })
|
||||
.distinct()
|
||||
}
|
||||
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
LaunchedEffect(activeIds, userId) {
|
||||
if (userId.isBlank()) return@LaunchedEffect
|
||||
activeIds.forEach { id ->
|
||||
if (titles.containsKey(id)) return@forEach
|
||||
val name = runCatching { app.jellyfin.item(userId, id)?.Name }.getOrNull()
|
||||
titles = titles + (id to (name?.takeIf { it.isNotBlank() } ?: id))
|
||||
}
|
||||
}
|
||||
|
||||
val rows = buildActivityRows(
|
||||
converting = converting,
|
||||
phone = phoneDownloads,
|
||||
activePhone = activePhone,
|
||||
glasses = glassesDownloads,
|
||||
titles = titles,
|
||||
)
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
ScreenPlaceholder("Ничего не конвертируется и не скачивается")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
|
||||
) {
|
||||
items(rows) { row ->
|
||||
ActivityRowView(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildActivityRows(
|
||||
converting: List<MirrorJob>,
|
||||
phone: Map<String, PhoneDownloadProgress>,
|
||||
activePhone: Set<String>,
|
||||
glasses: Map<String, Map<String, GlassesDownloadProgress>>,
|
||||
titles: Map<String, String>,
|
||||
): List<ActivityRow> {
|
||||
val out = ArrayList<ActivityRow>()
|
||||
val name = { id: String -> titles[id] ?: id }
|
||||
|
||||
val liveConverting = converting.filter { !it.itemId.isNullOrBlank() }
|
||||
if (liveConverting.isNotEmpty()) {
|
||||
out += ActivityRow.Header("Конвертируется", "h-conv")
|
||||
liveConverting.forEach { job ->
|
||||
val id = job.itemId.orEmpty()
|
||||
val label = when (job.status) {
|
||||
"processing" -> "обрабатывается"
|
||||
"queued" -> "в очереди"
|
||||
"new" -> "взято в работу"
|
||||
else -> job.status ?: "…"
|
||||
}
|
||||
out += ActivityRow.Convert(id, name(id), label, "conv-${job.id}")
|
||||
}
|
||||
}
|
||||
|
||||
val phoneActive = phone.filterKeys { activePhone.contains(it) }
|
||||
val glassesActive = glasses.filter { (itemId, files) ->
|
||||
itemId.isNotBlank() && files.values.any { it.phase == GlassesDownloadPhase.DOWNLOADING }
|
||||
}
|
||||
|
||||
if (phoneActive.isNotEmpty() || glassesActive.isNotEmpty()) {
|
||||
out += ActivityRow.Header("Скачивается", "h-dl")
|
||||
phoneActive.forEach { (id, p) ->
|
||||
out += ActivityRow.Download(id, name(id), "звук", p.percent, "dl-$id-phone")
|
||||
}
|
||||
glassesActive.forEach { (id, files) ->
|
||||
val video = files["video.mkv"] ?: files.values.firstOrNull()
|
||||
out += ActivityRow.Download(id, name(id), "видео", video?.percent ?: 0, "dl-$id-glasses")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityRowView(row: ActivityRow) {
|
||||
when (row) {
|
||||
is ActivityRow.Header -> SectionLabel(
|
||||
text = row.text,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
|
||||
is ActivityRow.Convert -> StatusBlock {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
color = PhoneAccent,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = row.title,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "зеркало: ${row.statusLabel}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ActivityRow.Download -> StatusBlock {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = row.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = PhoneTextDim,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${row.kind} ${row.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
ProgressBar(percent = row.percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.PhoneConfig
|
||||
import pw.binom.viewmate.phone.S3Presigner
|
||||
import pw.binom.viewmate.phone.log
|
||||
|
||||
/** Статусы зеркала, при которых конвертацию заказывать не надо. */
|
||||
private val MIRROR_BUSY_OR_DONE = setOf("done", "queued", "processing")
|
||||
|
||||
/**
|
||||
* Массовые действия страницы сезона/сериала: все эпизоды отправить на конвертацию,
|
||||
* скачать всё (звук → телефон, видео → очки) и удалить локальные файлы (телефон + очки).
|
||||
*
|
||||
* [episodes] — целевой набор эпизодов, [mirrors] — свежие статусы зеркал,
|
||||
* [label] — «сезон»/«сериал» для подписи, [contextName] — название для диалога удаления.
|
||||
*/
|
||||
@Composable
|
||||
fun BulkActionsRow(
|
||||
episodes: List<JellyfinItem>,
|
||||
mirrors: Map<String, MirrorJob>,
|
||||
label: String,
|
||||
contextName: String,
|
||||
onMessage: (String) -> Unit,
|
||||
onStateRefresh: () -> Unit,
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
val dm = app.downloadManager
|
||||
val phoneDownloaded by dm.downloadedIds.collectAsState()
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
|
||||
val statusOf: (JellyfinItem) -> String? = { mirrors[it.Id]?.status }
|
||||
val needConversion = episodes.filter { statusOf(it) !in MIRROR_BUSY_OR_DONE }
|
||||
val readyToDownload = episodes.filter { statusOf(it) == "done" }
|
||||
/** Если всё уже конвертировано — кнопку «конвертировать» не рисуем. */
|
||||
val convertable = needConversion.isNotEmpty()
|
||||
/** Готовые зеркала, которые ещё не скачаны (телефон/очки); если все скачано — кнопку не рисуем. */
|
||||
val toDownload = readyToDownload.filterNot { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
|
||||
val withLocal = episodes.count { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
|
||||
|
||||
/** Массовая конвертация: заказ по каждому эпизоду без активного/готового зеркала. */
|
||||
fun onConvert() {
|
||||
if (needConversion.isEmpty() || busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
for (ep in needConversion) {
|
||||
runCatching { app.mirror.createMirror(ep.Id) }
|
||||
.onSuccess { ok++ }
|
||||
.onFailure {
|
||||
fail++
|
||||
log("bulk", "mirror ${ep.Id}: ${it.message}")
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
onStateRefresh()
|
||||
onMessage(
|
||||
buildString {
|
||||
append("Конвертация: заказано $ok")
|
||||
if (fail > 0) append(", ошибок $fail")
|
||||
append(" ($label)")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Массовое скачивание готовых зеркал: звук на телефон (DownloadManager),
|
||||
* видео на очки (DownloadFiles через actions). Очки не подключены — только звук на телефон.
|
||||
*/
|
||||
fun onDownload() {
|
||||
if (toDownload.isEmpty() || busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
val actions = app.actions
|
||||
val glassesOk = app.server.hub.connected.value > 0 && actions != null
|
||||
var videoSent = 0
|
||||
for (ep in toDownload) {
|
||||
dm.startDownload(ep.Id)
|
||||
if (glassesOk) {
|
||||
val result = runCatching {
|
||||
actions.downloadToGlasses(ep.Id, sizes = dm.knownFileSizes(ep.Id)) {
|
||||
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||
}
|
||||
}
|
||||
if (result.isFailure) log("bulk", "скачивание на очки ${ep.Id}: ${result.exceptionOrNull()?.message}")
|
||||
else videoSent++
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
onStateRefresh()
|
||||
onMessage(
|
||||
buildString {
|
||||
append("Скачивание: звук → телефон, видео → очки ($videoSent) ($label)")
|
||||
if (!glassesOk) append(" [очки не подключены — только телефон]")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Удаление локальных файлов всех эпизодов: диск телефона + каталог контента очков. */
|
||||
fun onDelete() {
|
||||
if (withLocal == 0 || busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
var deleted = 0
|
||||
for (ep in episodes) {
|
||||
if (ep.Id !in phoneDownloaded && ep.Id !in glassesDownloadedIds) continue
|
||||
dm.delete(ep.Id)
|
||||
app.actions?.deleteDownloaded(ep.Id)
|
||||
deleted++
|
||||
}
|
||||
busy = false
|
||||
onStateRefresh()
|
||||
onMessage("Удалено локальное: $deleted эпизод(ов) ($label)")
|
||||
}
|
||||
}
|
||||
|
||||
if (convertable || toDownload.isNotEmpty() || withLocal > 0) {
|
||||
Text(
|
||||
text = "все эпизоды $label",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 4.dp, end = 14.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (convertable) {
|
||||
OutlinedButton(onClick = ::onConvert, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "конвертировать",
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (toDownload.isNotEmpty()) {
|
||||
OutlinedButton(onClick = ::onDownload, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "скачать",
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (withLocal > 0) {
|
||||
OutlinedButton(onClick = { confirmDelete = true }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "удалить локальное ($withLocal)",
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Удалить локальные файлы?") },
|
||||
text = {
|
||||
Text(
|
||||
"«$contextName»: с телефонов и очков удалятся уже скачанные " +
|
||||
"видео/звук по ${withLocal} эпизод(ам). Это действие необратимо.",
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Удалить") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -16,7 +18,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,6 +31,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -43,7 +49,10 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.WatchProgress
|
||||
import pw.binom.viewmate.phone.log
|
||||
|
||||
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
|
||||
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
|
||||
@@ -83,6 +92,8 @@ class CatalogState(
|
||||
private set
|
||||
var scrollOffset by mutableStateOf(0)
|
||||
private set
|
||||
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
|
||||
var onlyDownloaded by mutableStateOf(false)
|
||||
|
||||
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
|
||||
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
|
||||
@@ -195,7 +206,6 @@ class CatalogState(
|
||||
}
|
||||
}
|
||||
items = loaded
|
||||
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
@@ -204,6 +214,15 @@ class CatalogState(
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
// Зеркала — отдельный «мягкий» запрос: сбой/таймаут mirror.list не чистит
|
||||
// уже загруженный каталог, просто временно без бейджей.
|
||||
try {
|
||||
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("catalog", "зеркала: не удалось (${e.message}) — каталог без бейджей")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +249,36 @@ fun CatalogScreen(
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val userId by app.userId.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
// Системная кнопка «назад»: выход из вложенного пути (сериал→сезон, папки) на уровень выше
|
||||
BackHandler(enabled = state.canGoBack()) {
|
||||
state.back()
|
||||
}
|
||||
|
||||
// Все эпизоды сериала (для bulk-операций): по каждому сезону — список эпизодов.
|
||||
var seriesEpisodes by remember { mutableStateOf<List<JellyfinItem>?>(null) }
|
||||
val seasonIds = state.items.map { it.Id }
|
||||
LaunchedEffect(state.path, seasonIds, userId) {
|
||||
if (state.path !is CatalogState.Path.Series || userId.isBlank() || state.items.isEmpty()) {
|
||||
seriesEpisodes = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
seriesEpisodes = state.items.flatMap { season ->
|
||||
runCatching { app.jellyfin.items(userId, season.Id, listOf("Episode")) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
val bulkEpisodes = when (state.path) {
|
||||
is CatalogState.Path.Season -> state.items
|
||||
is CatalogState.Path.Series -> seriesEpisodes ?: emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
val bulkLabel = when (state.path) {
|
||||
is CatalogState.Path.Season -> "сезона"
|
||||
is CatalogState.Path.Series -> "сериала"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
|
||||
val gridState = rememberLazyGridState()
|
||||
@@ -247,6 +296,12 @@ fun CatalogScreen(
|
||||
}
|
||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
||||
val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
|
||||
// Статус видео на очках: скачанные itemId (свежий GlassesStatus) + живой прогресс
|
||||
// DownloadProgress (видео очки качают сами, звук — телефон).
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
// «Просмотрено» по элементам (watchprogress.json): тонкий бар на плашке + %/время у названия
|
||||
val watched by app.watchProgress.flow.collectAsState()
|
||||
|
||||
// подтянуть список, когда появился пользователь Jellyfin
|
||||
LaunchedEffect(userId) {
|
||||
@@ -255,12 +310,40 @@ fun CatalogScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
CatalogTopBar(state = state)
|
||||
if (bulkEpisodes.isNotEmpty()) {
|
||||
BulkActionsRow(
|
||||
episodes = bulkEpisodes,
|
||||
mirrors = state.mirrors,
|
||||
label = bulkLabel,
|
||||
contextName = state.subtitle(),
|
||||
onMessage = { message -> scope.launch { snackbar.showSnackbar(message) } },
|
||||
onStateRefresh = state::refresh,
|
||||
)
|
||||
}
|
||||
// фильтр «только скачанные (и частично)» — остаётся только то, что скачано на каком-то устройстве
|
||||
val visibleItems: List<JellyfinItem> = if (state.onlyDownloaded) {
|
||||
state.items.filter { item ->
|
||||
val id = item.Id
|
||||
downloaded.containsKey(id) ||
|
||||
localDownloaded.contains(id) ||
|
||||
glassesDownloadedIds.contains(id) ||
|
||||
glassesDownloads.containsKey(id)
|
||||
}
|
||||
} else {
|
||||
state.items
|
||||
}
|
||||
if (state.loading && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("загружаю…")
|
||||
} else if (state.error != null && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
||||
} else if (state.items.isEmpty()) {
|
||||
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
|
||||
} else if (visibleItems.isEmpty()) {
|
||||
ScreenPlaceholder(
|
||||
when {
|
||||
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
|
||||
state.query.isNotBlank() -> "ничего не нашлось"
|
||||
else -> "пусто"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
@@ -270,15 +353,20 @@ fun CatalogScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.items, key = { it.Id }) { item ->
|
||||
items(visibleItems, key = { it.Id }) { item ->
|
||||
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
|
||||
ItemCard(
|
||||
item = item,
|
||||
posterUrl = posterUrlOrNull(app, item),
|
||||
progress = watched[item.Id],
|
||||
badge = mirrorBadge(
|
||||
itemId = item.Id,
|
||||
job = state.mirrors[item.Id],
|
||||
downloaded = downloaded,
|
||||
localDownloaded = localDownloaded,
|
||||
glassesDownloading = videoProgress?.phase == DownloadPhase.DOWNLOADING,
|
||||
glassesDownloadingPercent = videoProgress?.percent,
|
||||
glassesDownloaded = item.Id in glassesDownloadedIds,
|
||||
),
|
||||
onClick = {
|
||||
when {
|
||||
@@ -303,6 +391,7 @@ fun CatalogScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
SnackbarHost(hostState = snackbar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +448,21 @@ private fun CatalogTopBar(state: CatalogState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.onlyDownloaded,
|
||||
onCheckedChange = { state.onlyDownloaded = it },
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = "только скачанные (и частично)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,9 +476,14 @@ private fun tabLabel(tab: CatalogTab): String = when (tab) {
|
||||
private fun ItemCard(
|
||||
item: JellyfinItem,
|
||||
posterUrl: String?,
|
||||
progress: WatchProgress?,
|
||||
badge: MirrorBadge,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// «Просмотрено»: % — когда известна длительность, иначе время; бар — снизу постера
|
||||
val percent = progress?.watchedPercent()
|
||||
val watchedLabel = progress?.takeIf { it.positionMs > 0L }
|
||||
?.let { p -> percent?.let { "$it%" } ?: msToClock(p.positionMs) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
@@ -392,6 +501,24 @@ private fun ItemCard(
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
if (percent != null && percent > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
|
||||
.background(PhoneLine),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(percent / 100f)
|
||||
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
|
||||
.background(PhoneAccent),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
@@ -408,13 +535,26 @@ private fun ItemCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.Name ?: item.Id,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = item.Name ?: item.Id,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (watchedLabel != null) {
|
||||
Text(
|
||||
text = watchedLabel,
|
||||
color = PhoneMuted,
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,375 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.agent.ChatMessage
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.Session
|
||||
|
||||
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */
|
||||
/**
|
||||
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
|
||||
* локального ассистента (та же очередь, что и голос с очков). Управление
|
||||
* сессиями: новая, переключение (выпадающий список ИЛИ ступенчатый горизонтальный
|
||||
* свайп ленты сообщений — на одну сессию за жест, без инерции; TZ-ui-four п.3),
|
||||
* сброс текущей.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatScreen() {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
|
||||
var messages by remember { mutableStateOf<List<UiMsg>>(emptyList()) }
|
||||
var input by remember { mutableStateOf("") }
|
||||
var sessionMenu by remember { mutableStateOf(false) }
|
||||
var activeId by remember { mutableStateOf<Long?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Загрузка списка сессий и сообщений при старте и при смене активной.
|
||||
LaunchedEffect(activeId) {
|
||||
val dao = app.chatDao
|
||||
val (sess, msgs) = withContext(Dispatchers.IO) {
|
||||
val all = dao.listSessions()
|
||||
val target = activeId ?: all.firstOrNull()?.id
|
||||
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
|
||||
all to history.map(::uiMsgOf)
|
||||
}
|
||||
sessions = sess
|
||||
activeId = activeId ?: sess.firstOrNull()?.id
|
||||
messages = msgs
|
||||
}
|
||||
|
||||
// Подписка на смену активной сессии извне (кнопки в PhoneApp).
|
||||
val externalActive by app.activeSessionId.collectAsState()
|
||||
LaunchedEffect(externalActive) {
|
||||
if (externalActive != null && externalActive != activeId) {
|
||||
activeId = externalActive
|
||||
}
|
||||
}
|
||||
|
||||
// Автопрокрутка вниз при новых сообщениях.
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
|
||||
}
|
||||
|
||||
// Периодическая перезагрузка сообщений из БД (ответ ассистента приходит
|
||||
// асинхронно — лента должна подхватывать его без ручного обновления)
|
||||
// + списка сессий (автоименование: имя проставляется при первом сообщении).
|
||||
LaunchedEffect(activeId) {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
val id = activeId
|
||||
if (id != null) {
|
||||
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map(::uiMsgOf) }
|
||||
if (fresh != messages) messages = fresh
|
||||
}
|
||||
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
|
||||
}
|
||||
}
|
||||
|
||||
// Ступенчатое переключение сессий свайпом (TZ-ui-four п.3): фиксированный
|
||||
// порог смещения в px (плотность экрана).
|
||||
val thresholdPx = with(LocalDensity.current) { CHAT_SESSION_SWIPE_THRESHOLD.toPx() }
|
||||
|
||||
/**
|
||||
* Ступенчатое переключение: ровно ОДИН шаг (+1 — следующая, −1 — предыдущая),
|
||||
* без перескока сразу на несколько сессий, без инерции/флинга;
|
||||
* за границей списка — ничего.
|
||||
*/
|
||||
fun switchSessionBy(delta: Int) {
|
||||
if (sessions.size < 2) return
|
||||
val idx = sessions.indexOfFirst { it.id == activeId }.let { if (it >= 0) it else 0 }
|
||||
val target = idx + delta
|
||||
if (target < 0 || target >= sessions.size) return
|
||||
val s = sessions[target]
|
||||
activeId = s.id
|
||||
app.selectChatSession(s.id)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Шапка: название + управление сессиями
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Порфирий",
|
||||
color = PhoneText,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Переключение сессий
|
||||
Box {
|
||||
TextButton(onClick = { sessionMenu = true }) {
|
||||
Text(
|
||||
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
|
||||
color = PhoneAccent,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
|
||||
sessions.forEach { s ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(s.title, fontSize = 12.sp) },
|
||||
onClick = {
|
||||
activeId = s.id
|
||||
app.selectChatSession(s.id)
|
||||
sessionMenu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) },
|
||||
onClick = {
|
||||
activeId = app.newChatSession()
|
||||
sessionMenu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// Сброс текущей
|
||||
TextButton(onClick = {
|
||||
app.resetChatSession()
|
||||
activeId?.let { id ->
|
||||
val history = app.chatDao.messagesForSession(id).map(::uiMsgOf)
|
||||
messages = history
|
||||
}
|
||||
}) {
|
||||
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
|
||||
val llmChoice by app.llmChoice.collectAsState()
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Лента сообщений
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
// Ступенчатая смена сессии (TZ-ui-four п.3): горизонтальный свайп
|
||||
// ленты за фиксированный порог = ±одна сессия (свайп влево —
|
||||
// следующая, вправо — предыдущая), мгновенно, без физики/инерции.
|
||||
// foundation 1.8: onHorizontalDrag даёт (change, суммарный сдвиг x с
|
||||
// начала жеста) — берём суммарный сдвиг без накопления по кадрам.
|
||||
.pointerInput(sessions.size) {
|
||||
var draggedPx = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onHorizontalDrag = { _, amount -> draggedPx = amount },
|
||||
onDragCancel = { draggedPx = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
draggedPx <= -thresholdPx -> switchSessionBy(1)
|
||||
draggedPx >= thresholdPx -> switchSessionBy(-1)
|
||||
else -> Unit
|
||||
}
|
||||
draggedPx = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (messages.isEmpty()) {
|
||||
Text(
|
||||
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
|
||||
color = PhoneMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
items(messages) { msg ->
|
||||
when (msg.role) {
|
||||
ChatRole.TOOL -> ToolBubble(msg)
|
||||
else -> MessageBubble(role = msg.role, text = msg.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Ввод
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Сообщение…", fontSize = 14.sp, color = PhoneMuted) },
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = PhoneText),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val text = input.trim()
|
||||
if (text.isEmpty()) return@Button
|
||||
input = ""
|
||||
messages = messages + UiMsg(ChatRole.USER, text)
|
||||
app.sendToAssistant(text)
|
||||
},
|
||||
) {
|
||||
Text("➤", fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Пузырь сообщения: user — справа акцентный, assistant — слева карточка. */
|
||||
@Composable
|
||||
private fun MessageBubble(role: ChatRole, text: String) {
|
||||
val isUser = role == ChatRole.USER
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Text(
|
||||
text = "Чат с Порфирием",
|
||||
text = text,
|
||||
color = PhoneText,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "в разработке (следующий этап)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
// Базовый шрифт сообщений (TZ-ui-four п.2): крупнее, чтобы читалось
|
||||
// без напряжения (14.sp → 20.sp, ~+43%); вёртка баллонов не менялась.
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.background(
|
||||
color = if (isUser) PhoneSurface else PhoneCard,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Элемент ленты: tool-сообщения дополнительно несут имя тула (в БД toolCallId = имя тула). */
|
||||
private data class UiMsg(
|
||||
val role: ChatRole,
|
||||
val content: String,
|
||||
val tool: String? = null,
|
||||
)
|
||||
|
||||
private fun uiMsgOf(m: ChatMessage) = UiMsg(
|
||||
role = m.role,
|
||||
content = m.content,
|
||||
tool = if (m.role == ChatRole.TOOL) m.toolCallId else null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Системный блок результата тула (ChatRole.TOOL): свёрнут по умолчанию —
|
||||
* иконка + «тул · имя», тап раскрывает текст результата. Система видна
|
||||
* по требованию, но не шумит в основной переписке.
|
||||
*/
|
||||
@Composable
|
||||
private fun ToolBubble(msg: UiMsg) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 340.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(PhoneCard)
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Build,
|
||||
contentDescription = "тул",
|
||||
tint = PhoneMuted,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "тул · ${msg.tool ?: "результат"}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (expanded) "свернуть" else "развернуть",
|
||||
tint = PhoneMuted,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = msg.content,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Фиксированный порог ступенчатого свайпа переключения сессий (TZ-ui-four п.3):
|
||||
* жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад.
|
||||
*/
|
||||
private val CHAT_SESSION_SWIPE_THRESHOLD = 120.dp
|
||||
|
||||
@@ -31,11 +31,13 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import java.util.Locale
|
||||
|
||||
/** Экраны приложения (простая навигация без nav-библиотеки). */
|
||||
enum class Screen {
|
||||
CATALOG,
|
||||
DETAILS,
|
||||
ACTIVITY,
|
||||
GLASSES,
|
||||
GLASSES_INFO,
|
||||
CHAT,
|
||||
@@ -45,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
|
||||
|
||||
private val bottomTabs = listOf(
|
||||
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
|
||||
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
|
||||
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
|
||||
BottomTab(Screen.CHAT, "💬", "Чат"),
|
||||
)
|
||||
@@ -76,11 +79,13 @@ fun BottomNavBar(current: Screen, onSelect: (Screen) -> Unit) {
|
||||
data class MirrorBadge(val text: String, val bg: Color, val fg: Color)
|
||||
|
||||
/**
|
||||
* Статус зеркала элемента:
|
||||
* Статус зеркала элемента (коротко для бейджа каталога):
|
||||
* - ⟳ N% — конвертируется;
|
||||
* - ↓ часть — что-то скачано локально (телефон) или на очки;
|
||||
* - ✓ — зеркало готово;
|
||||
* - ✗ — ошибка;
|
||||
* - ✗ — ошибка конвертации;
|
||||
* - ↓ N% — видео скачивается на очки;
|
||||
* - ▶ — можно смотреть (видео уже на очках);
|
||||
* - ✓ — зеркало готово (ещё не на очках);
|
||||
* - ↓ часть — скачано что-то локально (телефон) или на очки;
|
||||
* - нет — ничего нет.
|
||||
*/
|
||||
fun mirrorBadge(
|
||||
@@ -88,6 +93,9 @@ fun mirrorBadge(
|
||||
job: MirrorJob?,
|
||||
downloaded: Map<String, String>,
|
||||
localDownloaded: Set<String>,
|
||||
glassesDownloading: Boolean = false,
|
||||
glassesDownloadingPercent: Int? = null,
|
||||
glassesDownloaded: Boolean = false,
|
||||
): MirrorBadge {
|
||||
val status = job?.status
|
||||
val hasLocal = localDownloaded.contains(itemId)
|
||||
@@ -101,6 +109,16 @@ fun mirrorBadge(
|
||||
|
||||
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
|
||||
|
||||
// Видео уже летит на очки — конкретнее «✓ готово»
|
||||
glassesDownloading -> MirrorBadge(
|
||||
"↓ ${glassesDownloadingPercent ?: 0}%",
|
||||
PhoneBlue,
|
||||
PhoneBlueText,
|
||||
)
|
||||
|
||||
// Видео на очках — можно смотреть
|
||||
glassesDownloaded -> MirrorBadge("▶", PhoneGreen, PhoneGreenText)
|
||||
|
||||
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
|
||||
|
||||
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
|
||||
@@ -168,7 +186,7 @@ fun bytesToSize(bytes: Long?): String {
|
||||
val gb = 1024.0 * 1024 * 1024
|
||||
val mb = 1024.0 * 1024
|
||||
return when {
|
||||
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
|
||||
bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
|
||||
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
|
||||
else -> "%d КБ".format((bytes / 1024).toLong())
|
||||
}
|
||||
@@ -291,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionLabel(text: String) {
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
color = PhoneMuted,
|
||||
@@ -299,6 +317,6 @@ fun SectionLabel(text: String) {
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
modifier = modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import pw.binom.viewmate.core.phone.trackFileName
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.FileDownloadState
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
@@ -79,16 +78,16 @@ fun DetailsScreen(
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
|
||||
|
||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
|
||||
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
|
||||
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
|
||||
LaunchedEffect(item.Id, refreshKey) {
|
||||
loading = true
|
||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
||||
loading = false
|
||||
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
|
||||
delay(3_000)
|
||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,13 +178,24 @@ fun DetailsScreen(
|
||||
}
|
||||
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам
|
||||
val localPath = "file://$mediaDir/${item.Id}/video.mkv"
|
||||
// «Продолжить с того места»: сохранённая позиция (null — не смотрели / досмотрели)
|
||||
val resumeMs = if (alreadyPlaying) null else app.watchProgress.resumePosition(item.Id)
|
||||
if (resumeMs != null) {
|
||||
log("watch", "возобновление с сохранённой позиции: ${resumeMs} мс (${item.Name})")
|
||||
}
|
||||
val result: String
|
||||
if (alreadyPlaying) {
|
||||
result = "включил (видео уже играет на очках)"
|
||||
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
|
||||
} else {
|
||||
result = try {
|
||||
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk)
|
||||
actions!!.watch(
|
||||
item.Id,
|
||||
effectiveAudioIndex,
|
||||
localVideoPath = localPath,
|
||||
phoneAudioReady = audioOk,
|
||||
startPositionMs = resumeMs ?: 0L,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log("watch", "watch упал: ${e.message}")
|
||||
snackbar.showSnackbar("watch упал: ${e.message}")
|
||||
@@ -211,7 +221,7 @@ fun DetailsScreen(
|
||||
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
|
||||
} else {
|
||||
log("watch", "audio source: ${audio.source} (${audio.reason})")
|
||||
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L
|
||||
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else (resumeMs ?: 0L)
|
||||
app.audioSync.play(audio.source, startPositionMs)
|
||||
}
|
||||
} else {
|
||||
@@ -392,26 +402,28 @@ fun DetailsScreen(
|
||||
keys.forEach { key ->
|
||||
val name = finalFileName(key)
|
||||
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
|
||||
val isVideo = key == "video"
|
||||
val phoneRaw = phoneFileState(finalFile, partFile)
|
||||
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
|
||||
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
|
||||
} else {
|
||||
phoneRaw.second
|
||||
}
|
||||
val glassesState = if (glassesFully) {
|
||||
FileDownloadState.DONE to 100
|
||||
} else {
|
||||
glassesFileState(perFileProgress[name])
|
||||
// Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
|
||||
val state = when {
|
||||
isVideo && glassesFully -> FileDownloadState.DONE to 100
|
||||
isVideo -> glassesFileState(perFileProgress[name])
|
||||
else -> phoneRaw.first to phonePercent
|
||||
}
|
||||
DownloadFileRow(
|
||||
name = name,
|
||||
sizeLabel = fileSizeOrProgress(
|
||||
sizeBytes = knownSizeByName[name],
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
),
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
device = if (isVideo) "очки" else "телефон",
|
||||
sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
|
||||
"⏳ ${state.second}%"
|
||||
} else {
|
||||
bytesToSize(knownSizeByName[name])
|
||||
},
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -498,24 +510,13 @@ fun DetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */
|
||||
private fun fileSizeOrProgress(
|
||||
sizeBytes: Long?,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
): String = when {
|
||||
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
|
||||
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
|
||||
else -> bytesToSize(sizeBytes)
|
||||
}
|
||||
|
||||
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
|
||||
/** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */
|
||||
@Composable
|
||||
private fun DownloadFileRow(
|
||||
name: String,
|
||||
device: String,
|
||||
sizeLabel: String,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
state: Pair<FileDownloadState, Int>,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
|
||||
@@ -523,13 +524,9 @@ private fun DownloadFileRow(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("телефон", color = PhoneMuted, fontSize = 9.sp)
|
||||
Text(device, color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(phone)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("очки", color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(glasses)
|
||||
FileChip(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -24,6 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
|
||||
@@ -137,6 +141,10 @@ fun GlassesInfoScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LlmModelSection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,3 +173,118 @@ private fun InfoRow(label: String, value: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок выбора модели LLM ассистента: серверная (llm.binom.pw) или локальная
|
||||
* Gemma 4 E2B (LiteRT-LM) + управление скачиванием модели (прогресс, отмена).
|
||||
*/
|
||||
@Composable
|
||||
private fun LlmModelSection() {
|
||||
val app = PhoneApp.instance
|
||||
val choice by app.llmChoice.collectAsState()
|
||||
val llmState by app.localLlmState.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
SectionLabel("Модель LLM ассистента")
|
||||
StatusBlock {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LlmChoiceChip(
|
||||
label = "сервер",
|
||||
selected = choice == LlmModelChoice.REMOTE,
|
||||
onClick = { app.setLlmModelChoice(LlmModelChoice.REMOTE) },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
LlmChoiceChip(
|
||||
label = "локальная",
|
||||
selected = choice == LlmModelChoice.LOCAL,
|
||||
onClick = { app.setLlmModelChoice(LlmModelChoice.LOCAL) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (choice == LlmModelChoice.LOCAL) {
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Модель готова (Gemma 4 E2B, локально)",
|
||||
color = PhoneGreen,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Чип переключателя выбора модели (активный — акцентный фон). */
|
||||
@Composable
|
||||
private fun LlmChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (selected) PhoneAccent else PhoneSurface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (selected) PhoneBackground else PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Маленькая текстовая кнопка (скачать/отмена/повтор) в блоке LLM. */
|
||||
@Composable
|
||||
private fun LlmSmallAction(label: String, onClick: () -> Unit) {
|
||||
Text(
|
||||
text = label,
|
||||
color = PhoneAccent,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ fun GlassesScreen(
|
||||
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
|
||||
var scale by remember { mutableStateOf(app.state.scale) }
|
||||
|
||||
// высота экрана очков (0..1): 1 — у верха, 0 — нижняя кромка у низа
|
||||
var vertical by remember { mutableStateOf(app.state.vertical) }
|
||||
|
||||
val actions = app.actions
|
||||
|
||||
// активное скачивание на очки: первое с незавершённым файлом
|
||||
@@ -183,6 +186,31 @@ fun GlassesScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// высота экрана очков: 1.0 — у верха (как сейчас), 0.0 — нижняя кромка у низа (с учётом масштаба)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Высота: ${"%.2f".format(vertical)}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
Slider(
|
||||
value = vertical,
|
||||
onValueChange = { vertical = it },
|
||||
onValueChangeFinished = {
|
||||
app.state.vertical = vertical
|
||||
val a = actions
|
||||
if (a != null) scope.launch { a.applyVertical(vertical) }
|
||||
},
|
||||
valueRange = 0f..1f,
|
||||
steps = 9, // шаг 0.1
|
||||
)
|
||||
}
|
||||
|
||||
// активная загрузка контента на очки
|
||||
if (activeGlassesItem != null) {
|
||||
Text(
|
||||
@@ -360,6 +388,9 @@ private fun GlassesStatusCard(
|
||||
fg = if (playing) PhoneBlueText else PhoneMuted,
|
||||
)
|
||||
}
|
||||
|
||||
// Полевое испытание либы rayneo-mercury (BLE-протокол очков)
|
||||
MercuryPanel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.phone.MercuryBridge
|
||||
|
||||
/**
|
||||
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
|
||||
* Управление очками: подключение, экран, яркость и WiFi.
|
||||
*/
|
||||
@Composable
|
||||
fun MercuryPanel() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by MercuryBridge.state.collectAsState()
|
||||
|
||||
var brightness by remember { mutableIntStateOf(state.brightness) }
|
||||
var autoBright by remember { mutableStateOf(state.automaticBrightness) }
|
||||
var wifiOn by remember { mutableStateOf(state.wifiEnabled) }
|
||||
var ssid by remember { mutableStateOf("") }
|
||||
var wifiPass by remember { mutableStateOf("") }
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
|
||||
// Синхронизируем локальные поля со статусом очков (после pullGeneralStatus).
|
||||
LaunchedEffect(state.brightness) { brightness = state.brightness }
|
||||
LaunchedEffect(state.automaticBrightness) { autoBright = state.automaticBrightness }
|
||||
LaunchedEffect(state.wifiEnabled) { wifiOn = state.wifiEnabled }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(PhoneCard)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "🛰 Mercury BLE (управление очками)",
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = state.statusText,
|
||||
color = if (state.lastError != null) PhoneRedText else PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (state.connected) {
|
||||
Text(
|
||||
text = "очки: ${state.glassesName ?: "?"} · BLE готов",
|
||||
color = PhoneBlueText,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 10.dp)) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.connectFirst() } },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("подключить очки", fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.disconnect() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text("отключить", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = { MercuryBridge.startPairing() },
|
||||
enabled = !state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneAccent,
|
||||
contentColor = Color(0xFF10251A),
|
||||
),
|
||||
) {
|
||||
Text("🔗 паринг (QR)", fontSize = 12.sp)
|
||||
}
|
||||
if (state.pairCode != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { MercuryBridge.cancelPairing() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text("отмена", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.pairClassicAuto() } },
|
||||
enabled = !state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF3A3F47),
|
||||
contentColor = Color(0xFFD5DBDF),
|
||||
),
|
||||
) {
|
||||
Text("🔗 паринг (вручную)", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
val pairCode = state.pairCode
|
||||
if (pairCode != null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Паринг: отсканируйте QR картинку камерой очков (она должна смотреть на экран телефона)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
QrCode(pairCode, side = 280, modifier = Modifier.padding(top = 8.dp))
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
"Код: $pairCode",
|
||||
color = PhoneText,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.screenOff() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2B2F36),
|
||||
contentColor = Color(0xFF9AA0A6),
|
||||
),
|
||||
) {
|
||||
Text("🌙 погасить экран", fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.screenOn() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneAccent,
|
||||
contentColor = Color(0xFF10251A),
|
||||
),
|
||||
) {
|
||||
Text("☀️ включить экран", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Яркость ----
|
||||
Text(
|
||||
text = "Яркость: $brightness%",
|
||||
color = PhoneText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Slider(
|
||||
value = brightness.toFloat(),
|
||||
onValueChange = { brightness = it.roundToInt() },
|
||||
valueRange = 0f..100f,
|
||||
enabled = state.connected,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("Применить яркость", fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Switch(
|
||||
checked = autoBright,
|
||||
onCheckedChange = { nv ->
|
||||
autoBright = nv
|
||||
scope.launch { MercuryBridge.setAutoBrightness(nv) }
|
||||
},
|
||||
enabled = state.connected,
|
||||
)
|
||||
Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp)
|
||||
}
|
||||
|
||||
// ---- WiFi ----
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "WiFi",
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Switch(
|
||||
checked = wifiOn,
|
||||
onCheckedChange = { nv ->
|
||||
wifiOn = nv
|
||||
scope.launch { MercuryBridge.wifiToggle(nv) }
|
||||
},
|
||||
enabled = state.connected,
|
||||
)
|
||||
Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Button(
|
||||
onClick = { scope.launch {
|
||||
if (!state.wifiEnabled) MercuryBridge.wifiToggle(true)
|
||||
scanning = true
|
||||
delay(1500) // дать радио подняться
|
||||
MercuryBridge.wifiScan()
|
||||
delay(5000) // окно ожидания ответа очков
|
||||
scanning = false
|
||||
} },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp)
|
||||
}
|
||||
if (state.wifiSsid != null) {
|
||||
Text(
|
||||
"· ${state.wifiSsid}",
|
||||
color = PhoneBlueText,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = ssid,
|
||||
onValueChange = { ssid = it },
|
||||
label = { Text("SSID сети", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
enabled = state.connected,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
TextField(
|
||||
value = wifiPass,
|
||||
onValueChange = { wifiPass = it },
|
||||
label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
enabled = state.connected,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } },
|
||||
enabled = state.connected && ssid.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("Подключить к сети", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
if (state.wifiNetworks.isEmpty() && !scanning) {
|
||||
Text(
|
||||
"Список пуст: включи WiFi и нажми «Сканировать».",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.wifiNetworks.isNotEmpty()) {
|
||||
Text(
|
||||
"Найдено сетей: ${state.wifiNetworks.size}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
items(state.wifiNetworks) { net ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}",
|
||||
color = PhoneText,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { scope.launch {
|
||||
val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null
|
||||
MercuryBridge.wifiConnect(net.ssid, pwd)
|
||||
} },
|
||||
enabled = state.connected,
|
||||
) {
|
||||
Text("Подкл.", fontSize = 11.sp)
|
||||
}
|
||||
if (net.saved) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text("Забыть", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
|
||||
/**
|
||||
* Рендер QR-кода (pairCode) для паринга очков: очки сканируют его своей камерой.
|
||||
* В стоковом приложении RayNeo QR кодирует ровно pairCode (8 символов).
|
||||
*/
|
||||
@Composable
|
||||
fun QrCode(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
side: Int = 200,
|
||||
foreground: Color = Color.Black,
|
||||
background: Color = Color.White,
|
||||
) {
|
||||
val density = LocalDensity.current.density
|
||||
val sizePx = (side * density).toInt().coerceAtLeast(256)
|
||||
val bitmap = remember(text, sizePx, foreground, background) {
|
||||
renderQr(
|
||||
text = text,
|
||||
sizePx = sizePx,
|
||||
foreground = foreground.toArgb(),
|
||||
background = background.toArgb(),
|
||||
)
|
||||
}
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "QR-код паринга",
|
||||
modifier = modifier.size(side.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderQr(text: String, sizePx: Int, foreground: Int, background: Int): Bitmap {
|
||||
val bits = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
||||
val w = bits.width
|
||||
val h = bits.height
|
||||
val pixels = IntArray(w * h)
|
||||
for (y in 0 until h) {
|
||||
val rowOffset = y * w
|
||||
for (x in 0 until w) {
|
||||
pixels[rowOffset + x] = if (bits.get(x, y)) foreground else background
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
@@ -51,6 +51,8 @@ fun ViewMateApp() {
|
||||
},
|
||||
)
|
||||
|
||||
Screen.ACTIVITY -> ActivityScreen()
|
||||
|
||||
Screen.DETAILS -> {
|
||||
val item = selected
|
||||
if (item != null) {
|
||||
|
||||
@@ -12,58 +12,111 @@ import kotlin.test.assertTrue
|
||||
|
||||
class AudioSyncPlayerTest {
|
||||
|
||||
@Test
|
||||
fun noSeekWhenWithinThreshold() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_800, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs, "diff=800мс ≤ 1000мс — seek не нужен")
|
||||
assertNull(d.playing, "play/pause совпадают — не трогаем")
|
||||
}
|
||||
// --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek ---
|
||||
|
||||
@Test
|
||||
fun thresholdBoundaryDiff1000DoesNotSeek() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seeksToVideoPositionWhenDiffAboveThreshold() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
|
||||
fun noCorrectionWhenFullyAligned() {
|
||||
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, d.speed, "полное совпадение — темп не трогаем")
|
||||
assertNull(d.seekToMs)
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seeksWhenAudioAheadOfVideo() {
|
||||
val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
|
||||
fun speedStaysFlatInsideTriggerBand() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_100, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, d.speed, "diff=100мс внутри гистерезиса [-150;+150] — дребезга нет")
|
||||
assertNull(d.seekToMs)
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speedsUpWhenAudioLagsBehind() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_300, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f + 300f * DRIFT_GAIN, d.speed, "звук отстаёт — догоняем ускорением ${1f + 300f * DRIFT_GAIN}")
|
||||
assertNull(d.seekToMs, "diff=300мс внутри HARD-порога — seek не нужен")
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun slowsDownWhenAudioAhead() {
|
||||
val d = decideSync(audioPosition = 10_300, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f - 300f * DRIFT_GAIN, d.speed, "звук впереди — тормозим до ${1f - 300f * DRIFT_GAIN}")
|
||||
assertNull(d.seekToMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun smallDriftJustOutsideBandIsProportional() {
|
||||
val d = decideSync(audioPosition = 10_151, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f - 151f * DRIFT_GAIN, d.speed, "diff=151мс (чуть за зоной) — слабый пропорциональный темп")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speedSaturatesAtMaxDrift() {
|
||||
val behind = decideSync(audioPosition = 9_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f + MAX_DRIFT_SPEED, behind.speed, "diff=−1000мс → 1+0.2 упирается в потолок ${1f + MAX_DRIFT_SPEED}")
|
||||
val ahead = decideSync(audioPosition = 11_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f - MAX_DRIFT_SPEED, ahead.speed, "diff=+1000мс → 1−0.2 упирается в потолок ${1f - MAX_DRIFT_SPEED}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bandBoundaryExactly150MsStaysFlat() {
|
||||
val ahead = decideSync(audioPosition = 10_150, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, ahead.speed, "diff=+150мс — граница закрытой зоны, темп 1f")
|
||||
val behind = decideSync(audioPosition = 9_850, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, behind.speed, "diff=-150мс — граница закрытой зоны, темп 1f")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardSeekWhenAudioFarBehind() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 16_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(16_000L, d.seekToMs, "diff=6000мс > 5000мс — жёсткий seek на позицию очков")
|
||||
assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко")
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardSeekWhenAudioFarAhead() {
|
||||
val d = decideSync(audioPosition = 26_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(20_000L, d.seekToMs)
|
||||
assertEquals(1f, d.speed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardThresholdBoundaryDoesNotSeek() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs, "diff=2000мс не БОЛЬШЕ порога — подгоняем скоростью, без рывка")
|
||||
assertEquals(1f + MAX_DRIFT_SPEED, d.speed, "diff=2000мс → 1+0.4 — упирается в потолок ${1f + MAX_DRIFT_SPEED}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardThresholdPlusOneSeeks() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 15_001, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(15_001L, d.seekToMs, "diff=5001мс > 5000мс — уже жёсткий seek")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playWhenVideoPlayingButAudioPaused() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true)
|
||||
assertNull(d.seekToMs)
|
||||
assertEquals(1f, d.speed, "play/pause влияние только на playing, не на скорость")
|
||||
assertEquals(true, d.playing, "очки играют — аудио должно играть")
|
||||
assertNull(d.seekToMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pauseWhenVideoPausedButAudioPlaying() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
|
||||
assertNull(d.seekToMs)
|
||||
assertEquals(1f, d.speed)
|
||||
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
|
||||
assertNull(d.seekToMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seekAndPlayApplyTogether() {
|
||||
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true)
|
||||
assertEquals(8_000L, d.seekToMs)
|
||||
val d = decideSync(audioPosition = 5_000, videoPosition = 15_000, audioPlaying = false, videoPlaying = true)
|
||||
assertEquals(15_000L, d.seekToMs)
|
||||
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noCorrectionWhenFullyAligned() {
|
||||
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs)
|
||||
assertNull(d.playing)
|
||||
assertEquals(1f, d.speed)
|
||||
}
|
||||
|
||||
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
|
||||
|
||||
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
|
||||
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.server.testing.testApplication
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
@@ -23,24 +22,49 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
|
||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||
import pw.binom.viewmate.phone.stt.WavPcm
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
|
||||
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
|
||||
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
|
||||
*/
|
||||
class GlassesServerTest {
|
||||
|
||||
private val json = protocolJson
|
||||
|
||||
@Test
|
||||
fun helloGetsWelcome() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
|
||||
private fun startServer(): Pair<GlassesServer, Int> {
|
||||
val port = freePort()
|
||||
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
|
||||
server.start()
|
||||
return server to port
|
||||
}
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
private fun freePort(): Int {
|
||||
val s = java.net.ServerSocket(0)
|
||||
val p = s.localPort
|
||||
s.close()
|
||||
return p
|
||||
}
|
||||
|
||||
@Test
|
||||
fun helloGetsWelcome() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
||||
val frame = incoming.receive() as Frame.Text
|
||||
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||
@@ -50,12 +74,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gestureIsProcessed() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
fun gestureIsProcessed() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
||||
}
|
||||
@@ -64,28 +85,22 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectedCounterTracksSessions() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) { }
|
||||
fun connectedCounterTracksSessions() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
|
||||
awaitTrue { hub.connected.value == 0 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun broadcastReachesAllGlasses() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
fun broadcastReachesAllGlasses() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
coroutineScope {
|
||||
val opened = Channel<Unit>(2)
|
||||
val received = Channel<HostToGlasses>(2)
|
||||
|
||||
val jobs = (1..2).map {
|
||||
launch {
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
opened.send(Unit)
|
||||
while (true) {
|
||||
val frame = incoming.receive()
|
||||
@@ -115,12 +130,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
|
||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
|
||||
}
|
||||
@@ -131,12 +143,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(
|
||||
json.encodeToString(
|
||||
GlassesToHost.serializer(),
|
||||
@@ -163,6 +172,81 @@ class GlassesServerTest {
|
||||
assertEquals("0.2.0", hub.appVersion.value)
|
||||
}
|
||||
|
||||
// ---------- StopStt → SttCancel/SttDone (TASK-cancel-listen.md) ----------
|
||||
|
||||
/** StopStt(cancel=true) — двойной тап-отмена на очках: очки получают SttCancel(reason=cancel). */
|
||||
@Test
|
||||
fun stopSttCancelSendsSttCancelReasonCancel() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = true)))
|
||||
val frame = incoming.receive() as Frame.Text
|
||||
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||
val cancel = assertIs<SttCancel>(msg)
|
||||
assertEquals(SttCancel.REASON_CANCEL, cancel.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/** StopStt(cancel=false) с пустым текстом — очки возвращаются в «слушаю»: SttCancel(reason=user). */
|
||||
@Test
|
||||
fun stopSttEmptyTextBroadcastsSttCancelReasonUser() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
|
||||
val frame = incoming.receive() as Frame.Text
|
||||
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||
val cancel = assertIs<SttCancel>(msg)
|
||||
assertEquals(SttCancel.REASON_USER, cancel.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): весь принятый PCM
|
||||
* распознаётся одним вызовом на буфер сессии, фейк-распознаватель
|
||||
* даёт «привет» → очки получают SttDone с непустым текстом.
|
||||
*/
|
||||
@Test
|
||||
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
|
||||
hub.sttFactory = {
|
||||
SttStreamer(
|
||||
stt = FakePhraseRecognizer(),
|
||||
onSilence30s = { },
|
||||
onAutoFinished = { },
|
||||
)
|
||||
}
|
||||
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
// кидать по 3200 байт (100 мс @ 16 кГц s16le mono)
|
||||
var off = 0
|
||||
while (off < pcm.size) {
|
||||
val end = minOf(off + 3200, pcm.size)
|
||||
send(json.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end))))
|
||||
off = end
|
||||
}
|
||||
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
|
||||
val frame = incoming.receive() as Frame.Text
|
||||
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||
val done = assertIs<SttDone>(msg)
|
||||
assertTrue(done.full.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- помощники ----------
|
||||
|
||||
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking { block(port, server.hub) }
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun wsClient() = io.ktor.client.HttpClient {
|
||||
install(WebSockets)
|
||||
}
|
||||
|
||||
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!condition()) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import pw.binom.viewmate.phone.agent.ChatMessage
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** OpenAI-HTTP-сервер (Ktor): /v1/chat/completions (stream=false|true), маппинг ролей, ошибки. */
|
||||
class OpenAiHttpServerTest {
|
||||
|
||||
private val json = Json
|
||||
|
||||
private class FakeLlm(
|
||||
private val text: String = "Привет, мир!",
|
||||
private val failure: String? = null,
|
||||
) : LlmClient {
|
||||
var calls = 0
|
||||
var lastSystem: String? = null
|
||||
var lastMessages: List<ChatMessage> = emptyList()
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
calls++
|
||||
lastSystem = system
|
||||
lastMessages = messages
|
||||
failure?.let { throw IllegalStateException(it) }
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
private val chatBody =
|
||||
"""{"model":"any","stream":false,"messages":[{"role":"user","content":"Привет"}]}"""
|
||||
|
||||
private fun call(body: String, llm: LlmClient?): OpenAiHttpServer.ChatOutcome =
|
||||
runBlocking { OpenAiHttpServer.handleChat(llm, body) }
|
||||
|
||||
@Test
|
||||
fun nonStream_returnsCompletionJson() {
|
||||
val llm = FakeLlm()
|
||||
val outcome = call(chatBody, llm) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(200, outcome.code)
|
||||
val body = outcome.body
|
||||
assertTrue(body.contains("\"object\":\"chat.completion\""))
|
||||
assertTrue(body.contains("\"content\":\"Привет, мир!\""))
|
||||
assertTrue(body.contains("\"finish_reason\":\"stop\""))
|
||||
assertTrue(body.contains("\"id\":\"chatcmpl-"))
|
||||
assertEquals(1, llm.calls)
|
||||
assertEquals(listOf(ChatMessage.of(ChatRole.USER, "Привет")), llm.lastMessages)
|
||||
assertEquals(null, llm.lastSystem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemAndDeveloperRoles_goToSystem() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"system","content":"S1"},{"role":"developer","content":"S2"},{"role":"user","content":"U"},{"role":"assistant","content":"A"}]}""",
|
||||
llm,
|
||||
)
|
||||
assertEquals("S1\nS2", llm.lastSystem)
|
||||
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), llm.lastMessages.map { it.role })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolRole_mapsToToolMessage() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":"U"},{"role":"tool","tool_call_id":"t1","content":"результат"}]}""",
|
||||
llm,
|
||||
)
|
||||
val tool = llm.lastMessages.last()
|
||||
assertEquals(ChatRole.TOOL, tool.role)
|
||||
assertEquals("t1", tool.toolCallId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentAsArrayOfTextParts_joined() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":[{"type":"text","text":"часть1"},{"type":"text","text":"часть2"}]}]}""",
|
||||
llm,
|
||||
)
|
||||
assertEquals("часть1\nчасть2", llm.lastMessages.last().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantNullContentWithToolCalls_decodes() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":"U"},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}]}""",
|
||||
llm,
|
||||
)
|
||||
val roles = llm.lastMessages.map { it.role }
|
||||
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), roles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream_emitsSseChunksAndDone() {
|
||||
val llm = FakeLlm()
|
||||
val outcome = call(
|
||||
"""{"model":"any","stream":true,"messages":[{"role":"user","content":"Привет"}]}""",
|
||||
llm,
|
||||
) as OpenAiHttpServer.ChatOutcome.Sse
|
||||
val data = outcome.frames
|
||||
assertTrue(data.any { it.endsWith("[DONE]\n\n") || it == "data: [DONE]\n\n" })
|
||||
val chunks = data.filter { !it.contains("[DONE]") }.map {
|
||||
json.decodeFromString(JsonElement.serializer(), it.removePrefix("data: ").trim())
|
||||
}
|
||||
val choices = chunks.map { chunk ->
|
||||
chunk.jsonObject["choices"]!!.jsonArray[0].jsonObject
|
||||
}
|
||||
val deltas = choices.map { it["delta"]!!.jsonObject }
|
||||
assertEquals("assistant", deltas.first()["role"]!!.jsonPrimitive.content)
|
||||
val full = deltas.mapNotNull { it["content"]?.jsonPrimitive?.content }.joinToString("")
|
||||
assertEquals("Привет, мир!", full)
|
||||
assertEquals("stop", choices.last()["finish_reason"]!!.jsonPrimitive.content)
|
||||
assertTrue(chunks.all { it.jsonObject["object"]!!.jsonPrimitive.content == "chat.completion.chunk" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidBody_400() {
|
||||
val outcome = call("не json", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyMessages_400() {
|
||||
val outcome = call("""{"messages":[]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRole_400() {
|
||||
val outcome = call("""{"messages":[{"role":"alien","content":"hi"}]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noLlm_503() {
|
||||
val outcome = call(chatBody, null) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(503, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun llmFailure_502() {
|
||||
val outcome = call(chatBody, FakeLlm(failure = "boom")) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(502, outcome.code)
|
||||
assertTrue(outcome.body.contains("boom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allIps_containsLoopback() {
|
||||
assertTrue(OpenAiHttpServer.allIps().contains("127.0.0.1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class WatchProgressStoreTest {
|
||||
|
||||
private fun tempDir(name: String): File = createTempDirectory(name).toFile()
|
||||
|
||||
private fun newStore(): Pair<WatchProgressStore, File> {
|
||||
val dir = tempDir("watch-progress")
|
||||
return WatchProgressStore(dir) to dir
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsert and resume position`() {
|
||||
val (store, _) = newStore()
|
||||
store.upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
|
||||
|
||||
assertEquals(3_600_000L, store.resumePosition("item-1"))
|
||||
assertEquals(50, store.watchedPercent("item-1"))
|
||||
assertNull(store.resumePosition("other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume returns null for short or finished viewing`() {
|
||||
val (store, _) = newStore()
|
||||
store.upsert("short", positionMs = 10_000L, durationMs = 7_200_000L)
|
||||
assertNull(store.resumePosition("short"))
|
||||
|
||||
val finishedMs = (7_200_000L * WatchProgressStore.FINISHED_RATIO).toLong() + 1000L
|
||||
store.upsert("finished", positionMs = finishedMs, durationMs = 7_200_000L)
|
||||
assertNull(store.resumePosition("finished"))
|
||||
|
||||
val almost = 7_200_000L * 0.90
|
||||
store.upsert("almost", positionMs = almost.toLong(), durationMs = 7_200_000L)
|
||||
assertNotNull(store.resumePosition("almost"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backward and duplicate positions are not written`() {
|
||||
val (store, dir) = newStore()
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||
val writtenAfterAdvance = File(dir, "watchprogress.json").readText()
|
||||
|
||||
store.upsert("item-1", positionMs = 55_000L, durationMs = 7_200_000L)
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||
assertEquals(writtenAfterAdvance, File(dir, "watchprogress.json").readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `position beyond duration is rejected`() {
|
||||
val (store, dir) = newStore()
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||
val before = File(dir, "watchprogress.json").readText()
|
||||
|
||||
store.upsert("item-1", positionMs = 99_000_000L, durationMs = 7_200_000L)
|
||||
assertEquals(before, File(dir, "watchprogress.json").readText())
|
||||
assertEquals(60_000L, store.resumePosition("item-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration discovered later is persisted`() {
|
||||
val (store, dir) = newStore()
|
||||
store.upsert("item-1", positionMs = 60_000L)
|
||||
assertNull(store.watchedPercent("item-1"))
|
||||
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 120_000L)
|
||||
assertEquals(50, store.watchedPercent("item-1"))
|
||||
assertEquals("watchprogress.json", File(dir, "watchprogress.json").name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress survives reload`() {
|
||||
val dir = tempDir("watch-progress-reload")
|
||||
WatchProgressStore(dir).upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
|
||||
|
||||
val reloaded = WatchProgressStore(dir)
|
||||
assertEquals(3_600_000L, reloaded.resumePosition("item-1"))
|
||||
assertEquals(50, reloaded.watchedPercent("item-1"))
|
||||
assertEquals(mapOf("item-1" to WatchProgress(3_600_000L, 7_200_000L)), reloaded.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown duration gives percent null but resume works`() {
|
||||
val (store, _) = newStore()
|
||||
store.upsert("item-1", positionMs = 3_600_000L)
|
||||
assertNull(store.watchedPercent("item-1"))
|
||||
assertEquals(3_600_000L, store.resumePosition("item-1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Механика агента: три состояния промпта, бесшовная подмена, валидация
|
||||
* аргументов, невидимые тулы, таймаут (инжект часов), по-сессийность.
|
||||
*/
|
||||
class AgentToolkitTest {
|
||||
|
||||
private var now = 10_000_000L
|
||||
|
||||
private class Fixture(val toolkit: AgentToolkit, val calls: MutableList<String>, val shown: MutableList<String>)
|
||||
|
||||
private fun fixture(withExecutor: Boolean = true): Fixture {
|
||||
now = 10_000_000L
|
||||
val calls = mutableListOf<String>()
|
||||
val shown = mutableListOf<String>()
|
||||
val registry = ToolsetRegistry(timeoutMs = ToolsetRegistry.DEFAULT_TIMEOUT_MS, clock = { now })
|
||||
val ts = Toolset(
|
||||
name = "media",
|
||||
description = "Управление медиа и воспроизведением (search/info/play)",
|
||||
tools = listOf(
|
||||
ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")),
|
||||
ToolSignature("info", "Детали фильма.", args = listOf("id")),
|
||||
ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")),
|
||||
),
|
||||
)
|
||||
if (withExecutor) {
|
||||
registry.register(ts) { tset, tool, args ->
|
||||
calls.add("$tset.$tool ${args.entries.joinToString { "${'{' }${it.key}=${it.value}${'}'}" }}")
|
||||
"Результат $tool: ${args.entries.joinToString { "${it.key}=${it.value}" }}"
|
||||
}
|
||||
} else {
|
||||
registry.register(ts, null)
|
||||
}
|
||||
val skills = SkillRegistry(
|
||||
listOf(
|
||||
Skill("media", "Поиск фильмов, детали, запуск/управление.", "ТЕЛО: media_search|query=..."),
|
||||
Skill("web", "Как сделать веб-поиск.", "Шаги веб-поиска..."),
|
||||
),
|
||||
)
|
||||
return Fixture(
|
||||
toolkit = AgentToolkit(skills = skills, toolsets = registry, showText = { shown.add(it) }),
|
||||
calls = calls,
|
||||
shown = shown,
|
||||
)
|
||||
}
|
||||
|
||||
private fun run(d: AgentToolkit.Dispatch): String =
|
||||
(d as AgentToolkit.Dispatch.Run).result
|
||||
|
||||
// ---------- три состояния промпта ----------
|
||||
|
||||
@Test
|
||||
fun `состояние 1 нет активных тулсетов, каталог и скиллы`() {
|
||||
val p = fixture().toolkit.systemPrompt(1)
|
||||
assertTrue(p.contains("Ядро:"))
|
||||
assertTrue(p.contains("get_current_time"))
|
||||
assertTrue(p.contains("show_text|text"))
|
||||
assertTrue(p.contains("enable_toolset|имя"))
|
||||
assertTrue(p.contains("Каталог тулсетов"))
|
||||
assertTrue(p.contains("media — Управление медиа и воспроизведением"))
|
||||
// скиллы: по строке на каждый
|
||||
assertTrue(p.contains("media — Поиск фильмов, детали, запуск/управление."))
|
||||
assertTrue(p.contains("web — Как сделать веб-поиск."))
|
||||
// описания тул набора ещё не раскрыты (набор не активен)
|
||||
assertFalse(p.contains("Активные тулсеты"))
|
||||
assertFalse(p.contains("media_search|query"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enable невидимый, промпт пересобирается, состояние 3`() {
|
||||
val f = fixture()
|
||||
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(d is AgentToolkit.Dispatch.Silent)
|
||||
val p = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p.contains("Активные тулсеты: media"))
|
||||
assertTrue(p.contains("media_search|query — Поиск фильмов"))
|
||||
assertTrue(p.contains("media_play|id,seek_ms? — Запуск/управление"))
|
||||
// связанный скилл убран из промпта (имя скилла = имя тулсета)
|
||||
assertFalse(p.contains("media — Поиск фильмов"))
|
||||
// остальные скиллы на месте
|
||||
assertTrue(p.contains("web — Как сделать веб-поиск."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `повторный enable, напоминание, уже активен`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val d2 = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
|
||||
val p1 = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p1.contains("Тулсет \"media\" уже активен, таймаут продлён"))
|
||||
// напоминание одноразовое: следующий промпт без него
|
||||
val p2 = f.toolkit.systemPrompt(1)
|
||||
assertFalse(p2.contains("уже активен"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disable обратно в состояние 1, двойной disable не ошибка`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
|
||||
val p1 = f.toolkit.systemPrompt(1)
|
||||
assertFalse(p1.contains("Активные тулсеты"))
|
||||
assertTrue(p1.contains("media — Поиск фильмов")) // скилл вернулся в индекс
|
||||
val d2 = f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(d2 is AgentToolkit.Dispatch.Silent)
|
||||
val p2 = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p2.contains("Нет активных тулсетов"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `неизвестный набор, напоминание с доступными в промпте`() {
|
||||
val f = fixture()
|
||||
val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "other"))
|
||||
assertTrue(d is AgentToolkit.Dispatch.Silent)
|
||||
val p = f.toolkit.systemPrompt(1)
|
||||
assertTrue(p.contains("Набора \"other\" нет"))
|
||||
assertTrue(p.contains("media"))
|
||||
}
|
||||
|
||||
// ---------- бесшовная подмена ----------
|
||||
|
||||
@Test
|
||||
fun `подмена, аргументы не совпадают, вызов явно не выполнялся`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
|
||||
assertTrue(msg.contains("Тулсет media активирован"))
|
||||
assertTrue(msg.contains("Реестр:"))
|
||||
assertTrue(msg.contains("media_search"))
|
||||
assertTrue(msg.contains("ещё НЕ выполнялся"))
|
||||
assertTrue(msg.contains("вызови media_search с аргументами: query"))
|
||||
// тул не выполнялся реально
|
||||
assertTrue(f.calls.isEmpty())
|
||||
// набор стал активен (подмена = enable)
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `подмена, валидные аргументы, выполнение без второго круга`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "Рассел")))
|
||||
assertTrue(msg.contains("Результат media_search"))
|
||||
assertEquals(1, f.calls.size)
|
||||
assertTrue(f.calls.single().contains("media.media_search"))
|
||||
assertTrue(f.calls.single().contains("query=Рассел"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
}
|
||||
|
||||
// ---------- валидация и маршрутизация ----------
|
||||
|
||||
@Test
|
||||
fun `активный тулсет, не хватает аргументов, ошибка со схемой`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap()))
|
||||
assertTrue(msg.contains("аргументы неверны"))
|
||||
assertTrue(msg.contains("query"))
|
||||
assertTrue(msg.contains("Схема: media_search|query"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `маршрутизация по префкису в набор, вызов идёт в executor`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_info", mapOf("id" to "123")))
|
||||
assertTrue(msg.contains("Результат media_info"))
|
||||
assertTrue(f.calls.single().contains("media.media_info"))
|
||||
assertTrue(f.calls.single().contains("id=123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `тул не входит в сигнатуры набора`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_bogus", emptyMap()))
|
||||
assertTrue(msg.contains("Тул \"media_bogus\" в тулсете \"media\" нет"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `неизвестный тул, ответ со списком доступных`() {
|
||||
val msg = run(fixture().toolkit.dispatch(1, "frobo", emptyMap()))
|
||||
assertTrue(msg.contains("Тул \"frobo\" не найден"))
|
||||
assertTrue(msg.contains("enable_toolset"))
|
||||
assertTrue(msg.contains("media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `активный тулсет без исполнителя, понятное сообщение`() {
|
||||
val f = fixture(withExecutor = false)
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "x")))
|
||||
assertTrue(msg.contains("ещё не реализованы"))
|
||||
}
|
||||
|
||||
// ---------- ядро тулов ----------
|
||||
|
||||
@Test
|
||||
fun `read_skill, весь текст скилла`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "media")))
|
||||
assertTrue(msg.contains("ТЕЛО: media_search|query=..."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read_skill неизвестный скилл, прощающая подсказка`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "неттакого")))
|
||||
assertTrue(msg.contains("Скилл \"неттакого\" не найден"))
|
||||
assertTrue(msg.contains("media"))
|
||||
assertTrue(msg.contains("web"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show_text, текст шлётся в шир, результат и пропуск без дисплея`() {
|
||||
val f = fixture()
|
||||
val msg = run(f.toolkit.dispatch(1, "show_text", mapOf("text" to "Привет!")))
|
||||
assertEquals("Привет!", f.shown.single())
|
||||
assertTrue(msg.contains("Показано на очках: Привет!"))
|
||||
val bad = run(f.toolkit.dispatch(1, "show_text", emptyMap()))
|
||||
assertTrue(bad.contains("\"text\""))
|
||||
// без подключённого дисплея (showText = null) — понятный результат, не падение
|
||||
val bare = AgentToolkit(SkillRegistry(emptyList()), ToolsetRegistry(clock = { now }))
|
||||
val disconnected = run(bare.dispatch(1, "show_text", mapOf("text" to "X")))
|
||||
assertTrue(disconnected.contains("не показан"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_current_time, время в ISO`() {
|
||||
val msg = run(fixture().toolkit.dispatch(1, "get_current_time", emptyMap()))
|
||||
val ts = Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")
|
||||
assertTrue(ts.find(msg) != null, "не ISO 8601 в: $msg")
|
||||
}
|
||||
|
||||
// ---------- таймаут и сессии ----------
|
||||
|
||||
@Test
|
||||
fun `истёкший тулсет исчезает из промпта, скилл возвращается`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
// инжект часов: проскакиваем таймаут без реального ожидания 10 минут
|
||||
now += ToolsetRegistry.DEFAULT_TIMEOUT_MS + 1_000L
|
||||
val p = f.toolkit.systemPrompt(1)
|
||||
assertFalse(p.contains("Активные тулсеты"))
|
||||
assertFalse(p.contains("media_search|query"))
|
||||
assertTrue(p.contains("media — Поиск фильмов")) // скилл снова в индексе
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `активность по сессиям, разные сессии независимы`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
|
||||
// в сессии 2 вызов тула неактивного набора — подмена активирует только её
|
||||
val msg = run(f.toolkit.dispatch(2, "media_search", mapOf("query" to "X")))
|
||||
assertTrue(msg.contains("Результат media_search"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
assertTrue(f.toolkit.systemPrompt(2).contains("Активные тулсеты: media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disable в сессии 2 не трогает активный в сессии 1`() {
|
||||
val f = fixture()
|
||||
f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media"))
|
||||
f.toolkit.dispatch(2, "enable_toolset", mapOf("name" to "media"))
|
||||
f.toolkit.dispatch(2, "disable_toolset", mapOf("name" to "media"))
|
||||
assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media"))
|
||||
assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты"))
|
||||
}
|
||||
|
||||
// ---------- парсинг аргументов ----------
|
||||
|
||||
@Test
|
||||
fun `парсинг пар и голых значений, прощающий к лишнему`() {
|
||||
val tk = fixture().toolkit
|
||||
assertEquals(mapOf("name" to "media"), tk.parseArgs("media", listOf("name")))
|
||||
assertEquals(emptyMap<String, String>(), tk.parseArgs(null, listOf("name")))
|
||||
assertEquals(emptyMap<String, String>(), tk.parseArgs(" ", listOf("name")))
|
||||
assertEquals(mapOf("query" to "Расскл"), tk.parseArgs("query=Расскл", listOf("query")))
|
||||
assertEquals(
|
||||
mapOf("id" to "12", "seek_ms" to "300"),
|
||||
tk.parseArgs("id=12,seek_ms=300", listOf("id", "seek_ms?")),
|
||||
)
|
||||
// «голое» значение — первому обязательному аргументу
|
||||
assertEquals(mapOf("id" to "abc"), tk.parseArgs("abc", listOf("id", "seek_ms?")))
|
||||
// пар через «&» и лишние ключи — не ошибка
|
||||
val m = tk.parseArgs("query=x&extra=5", listOf("query"))
|
||||
assertEquals("x", m["query"])
|
||||
assertTrue(m.containsKey("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `схема аргументов, schemaFor, у тулов`() {
|
||||
val tk = fixture().toolkit
|
||||
assertEquals(emptyList(), tk.schemaFor("get_current_time"))
|
||||
assertEquals(listOf("text"), tk.schemaFor("show_text"))
|
||||
assertEquals(listOf("name"), tk.schemaFor("enable_toolset"))
|
||||
assertEquals(listOf("name"), tk.schemaFor("read_skill"))
|
||||
assertEquals(listOf("id", "seek_ms?"), tk.schemaFor("media_play"))
|
||||
assertEquals(emptyList(), tk.schemaFor("unknown_tool"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Тесты тулов ассистента — чистая логика без БД и Android.
|
||||
*/
|
||||
class AgentToolsTest {
|
||||
|
||||
@Test
|
||||
fun `тул времени возвращает ISO 8601 с датой и временем`() {
|
||||
val result = TimeTool.invoke("{}")
|
||||
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
|
||||
assertTrue(
|
||||
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
|
||||
"не ISO 8601: $result",
|
||||
)
|
||||
assertTrue(result.startsWith("20"), "год не 20xx: $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `реестр содержит тулу времени с описанием`() {
|
||||
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
|
||||
val desc = AgentTools.describeAll()
|
||||
assertTrue(desc.contains("get_current_time"))
|
||||
assertTrue(desc.contains("ISO 8601"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `тула времени не зависит от аргументов`() {
|
||||
// Пустой JSON и мусор — один результат (тула без параметров)
|
||||
val a = TimeTool.invoke("{}")
|
||||
val b = TimeTool.invoke("{\"x\":1}")
|
||||
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Тесты тул-канала на границе клиента: парсер текстового маркера
|
||||
* (remote) и OpenAI-JSON-описатель тула (local, нативный LiteRT-канал).
|
||||
*/
|
||||
class LlmClientTest {
|
||||
|
||||
// ---------- parseToolCallArgs (текстовый протокол) ----------
|
||||
|
||||
@Test
|
||||
fun parseArgsEmptyWhenNoArgs() {
|
||||
assertEquals(emptyMap<String, String>(), parseToolCallArgs(null, emptyList()))
|
||||
assertEquals(emptyMap<String, String>(), parseToolCallArgs("", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsNamedAndBare() {
|
||||
val args = parseToolCallArgs("query=old songs,seek_ms=30000", listOf("query", "seek_ms?"))
|
||||
assertEquals("old songs", args["query"])
|
||||
assertEquals("30000", args["seek_ms"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsBareValueGoesToFirstExpected() {
|
||||
val args = parseToolCallArgs("old songs", listOf("query"))
|
||||
assertEquals("old songs", args["query"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsDuplicateNameLastWins() {
|
||||
val args = parseToolCallArgs("id=1,id=2", listOf("id"))
|
||||
assertEquals("2", args["id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsUnknownNamePassedThrough() {
|
||||
val args = parseToolCallArgs("id=1,foo=bar", listOf("id"))
|
||||
assertEquals("1", args["id"])
|
||||
assertTrue(args.containsKey("foo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsQuotedValueKeepsQuotes() {
|
||||
// Парсер прощающий: кавычки — литеральные символы, пары режутся запятой
|
||||
val args = parseToolCallArgs("""a="hello world",b=1""", listOf("a", "b"))
|
||||
assertEquals("\"hello world\"", args["a"])
|
||||
assertEquals("1", args["b"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsUnknownFormatGoesToFirstRequired() {
|
||||
// Неизвестный формат (без `=`) не падает: всё сырое значение
|
||||
// уходит первому обязательному аргументу
|
||||
val args = parseToolCallArgs("""{"id":"c42"}""", listOf("id"))
|
||||
assertEquals("""{"id":"c42"}""", args["id"])
|
||||
}
|
||||
|
||||
// ---------- TOOL_CALL-маркер (текстовый протокол, remote) ----------
|
||||
|
||||
@Test
|
||||
fun markerFoundWithArgs() {
|
||||
val m = TOOL_CALL_RE.find("хорошо, TOOL_CALL: media_search|query=old songs,seek_ms=30000")
|
||||
assertNotNull(m)
|
||||
assertEquals("media_search", m.groupValues[1])
|
||||
assertEquals("query=old songs,seek_ms=30000", m.groupValues[2].trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerFoundWithoutArgs() {
|
||||
val m = TOOL_CALL_RE.find("TOOL_CALL: get_current_time")
|
||||
assertNotNull(m)
|
||||
assertEquals("get_current_time", m.groupValues[1])
|
||||
assertTrue(m.groupValues[2].isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerNotFoundInPlainText() {
|
||||
assertNull(TOOL_CALL_RE.find("Какое сейчас время?"))
|
||||
}
|
||||
|
||||
// ---------- ToolSpec ----------
|
||||
|
||||
@Test
|
||||
fun specArgNamesAndRequired() {
|
||||
val spec = ToolSpec("media_search", "поиск", listOf("query", "seek_ms?"))
|
||||
assertEquals(listOf("query", "seek_ms"), spec.argNames())
|
||||
assertEquals(listOf("query"), spec.requiredArgs())
|
||||
}
|
||||
|
||||
// ---------- toolJson (нативный LiteRT-канал) ----------
|
||||
|
||||
@Test
|
||||
fun toolJsonHasFunctionFields() {
|
||||
val json = toolJson(ToolSpec("media_search", "поиск трека", listOf("query", "seek_ms?")))
|
||||
val obj = Json.parseToJsonElement(json) as JsonObject
|
||||
assertEquals("media_search", (obj["name"] as JsonPrimitive).content)
|
||||
val params = obj["parameters"] as JsonObject
|
||||
val props = params["properties"] as JsonObject
|
||||
assertTrue(props.containsKey("query"))
|
||||
assertTrue(props.containsKey("seek_ms"))
|
||||
val required = params["required"] as JsonArray
|
||||
assertEquals(listOf("query"), required.map { (it as JsonPrimitive).content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolJsonWithoutArgsHasNoProperties() {
|
||||
val json = toolJson(ToolSpec("get_current_time", "текущее время"))
|
||||
val obj = Json.parseToJsonElement(json) as JsonObject
|
||||
val params = obj["parameters"] as JsonObject
|
||||
assertFalse(params.containsKey("properties"))
|
||||
assertFalse(params.containsKey("required"))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user