Compare commits
61 Commits
d585ddf576
..
main
| 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 |
@@ -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,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,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,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,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,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)
|
||||
|
||||
@@ -42,17 +42,19 @@ class BtGlassesTransport(
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* 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) {
|
||||
(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 =
|
||||
|
||||
@@ -4,7 +4,19 @@ 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 {
|
||||
@@ -42,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)
|
||||
}
|
||||
@@ -49,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. */
|
||||
@@ -58,6 +97,13 @@ 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/выход/вход в режим:
|
||||
@@ -72,6 +118,13 @@ class GlassesApp : Application() {
|
||||
*/
|
||||
val answerScrollDelta = MutableStateFlow(0f)
|
||||
|
||||
/**
|
||||
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
||||
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
|
||||
* (видео на очках не играет / данных ещё нет).
|
||||
*/
|
||||
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
|
||||
|
||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||
var mainActivity: MainActivity? = null
|
||||
|
||||
@@ -82,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") }
|
||||
@@ -98,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
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import pw.binom.viewmate.core.MediaCommand
|
||||
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
|
||||
@@ -34,6 +37,7 @@ 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
|
||||
@@ -98,6 +102,8 @@ class HostConnection(
|
||||
playing = snapshot.playing,
|
||||
itemId = snapshot.itemId,
|
||||
audioIndex = snapshot.audioIndex,
|
||||
sentTs = System.currentTimeMillis(),
|
||||
durationMs = snapshot.durationMs,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -243,9 +249,9 @@ class HostConnection(
|
||||
fun sendPlaybackPositionNow() {
|
||||
if (transport == null) return
|
||||
scope.launch {
|
||||
val (positionMs, playing, itemId, audioIndex) =
|
||||
val (positionMs, playing, itemId, audioIndex, durationMs) =
|
||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis(), durationMs = durationMs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,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()
|
||||
@@ -329,6 +340,16 @@ 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 -> {
|
||||
@@ -340,6 +361,13 @@ class HostConnection(
|
||||
|
||||
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}")
|
||||
@@ -351,9 +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 -> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -48,15 +48,13 @@ 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.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
|
||||
@@ -65,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`
|
||||
@@ -258,12 +252,13 @@ class MainActivity : BaseEventActivity() {
|
||||
if (app.movieController.pauseIfPlaying()) {
|
||||
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||
}
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
app.listening.value = true
|
||||
app.thinking.value = false
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/** Click (кино): плей/пауза — как сегодня. */
|
||||
@@ -274,14 +269,13 @@ class MainActivity : BaseEventActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
|
||||
private fun sendPhrase() {
|
||||
log("gesture", "Click → отправить фразу")
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.thinking.value = true
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||
}
|
||||
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = true
|
||||
app.sttSession.send()
|
||||
}
|
||||
|
||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||
@@ -293,23 +287,79 @@ class MainActivity : BaseEventActivity() {
|
||||
app.thinking.value = false
|
||||
app.answerScrollDelta.value = 0f
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
||||
/**
|
||||
* Возврат в «слушаю» после отмены распознавания (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 → отмена распознавания")
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.thinking.value = false
|
||||
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 {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
||||
app.hostConnection.sendToHost(SttText(text))
|
||||
}
|
||||
}
|
||||
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
|
||||
else -> {
|
||||
log("stt", "клик без голоса — остаюсь в «слушаю»")
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
app.sttSession.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
||||
private fun exitAssistantMode() {
|
||||
log("gesture", "DoubleClick → выход из режима (кино остаётся на паузе)")
|
||||
sttMic.stop()
|
||||
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
|
||||
@@ -353,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", "экран погас — пауза")
|
||||
}
|
||||
|
||||
@@ -439,11 +496,17 @@ fun ConnectionScreen() {
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
|
||||
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
||||
.offset(
|
||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||
// вертикаль (vertical) — только для видео; здесь контент всегда у верха
|
||||
)
|
||||
.background(Color.Black),
|
||||
) {
|
||||
Column(
|
||||
@@ -460,12 +523,16 @@ fun ConnectionScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||
}
|
||||
|
||||
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, // панель масштабируется вместе с контентом, но прибита к низу
|
||||
)
|
||||
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
||||
|
||||
+32
-1
@@ -2,6 +2,7 @@ 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):
|
||||
@@ -64,7 +65,12 @@ sealed interface GestureDecision {
|
||||
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
|
||||
data object NewPhrase : GestureDecision
|
||||
|
||||
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */
|
||||
/**
|
||||
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
|
||||
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
|
||||
* оверлей остаётся на экране даже при пустой истории диалога.
|
||||
* Сторона очков: StopStt(cancel=true) на телефон.
|
||||
*/
|
||||
data object CancelDictation : GestureDecision
|
||||
|
||||
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||
@@ -125,6 +131,18 @@ fun gestureDecision(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние после отмены записи ([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()
|
||||
@@ -142,6 +160,19 @@ fun overlayHeader(state: AssistantState): String? = when {
|
||||
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].
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -11,7 +11,10 @@ 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
|
||||
@@ -25,17 +28,23 @@ 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
|
||||
|
||||
/**
|
||||
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
||||
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
||||
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
||||
* «ответ виден» — прокручиваемый ответ (дельта скролла от жестов — [GlassesApp.answerScrollDelta]).
|
||||
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
|
||||
* каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
|
||||
* «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
|
||||
* в том же scale-контейнере, что у экранов. Режим общения явный
|
||||
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
|
||||
* Состояния: «слушаю»/«думаю...» — заголовок,
|
||||
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||
* [GlassesApp.answerScrollDelta]).
|
||||
*/
|
||||
@Composable
|
||||
fun AssistantOverlay() {
|
||||
@@ -44,46 +53,27 @@ fun AssistantOverlay() {
|
||||
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
|
||||
overlayBox(app, state)
|
||||
}
|
||||
|
||||
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
||||
@Composable
|
||||
private fun overlayBox(app: GlassesApp, state: AssistantState) {
|
||||
Binocular(scale = false) {
|
||||
val scale by app.scale.collectAsState()
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
val side = BinocularScreenSide.current
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
||||
.background(Color.Black.copy(alpha = 0.72f)),
|
||||
) {
|
||||
AssistantOverlayContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantOverlayContent(state: AssistantState) {
|
||||
val app = GlassesApp.instance
|
||||
val hasAnswer = state.assistantText.isNotBlank()
|
||||
val header = overlayHeader(state)
|
||||
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()
|
||||
|
||||
// Новый ответ приходит с обнулением answerScrollDelta (ShowText) — к началу текста.
|
||||
LaunchedEffect(state.assistantText) {
|
||||
scrollState.scrollTo(0)
|
||||
// Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению.
|
||||
LaunchedEffect(chatHistory.size, state.assistantText) {
|
||||
if (hasHistory || state.assistantText.isNotBlank()) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect],
|
||||
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
|
||||
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
||||
LaunchedEffect(Unit) {
|
||||
app.answerScrollDelta.collect { pending ->
|
||||
@@ -96,12 +86,78 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
||||
}
|
||||
}
|
||||
|
||||
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 (hasAnswer) Arrangement.Top else Arrangement.Center,
|
||||
verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center,
|
||||
) {
|
||||
if (!header.isNullOrBlank()) {
|
||||
Text(
|
||||
@@ -111,7 +167,39 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
if (hasAnswer) {
|
||||
// Строка статуса модели (только «слушаю» + модель не 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
|
||||
@@ -120,10 +208,13 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = state.assistantText,
|
||||
text = "ассистент: ${state.assistantText}",
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Center,
|
||||
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,14 +104,23 @@ fun GlassesMovieScreen(
|
||||
maxHeight = containerH - 25,
|
||||
)
|
||||
|
||||
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
|
||||
.offset(
|
||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
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) {
|
||||
@@ -121,16 +131,20 @@ fun GlassesMovieScreen(
|
||||
BinocularVideoPlayer(
|
||||
player = player,
|
||||
renderer = renderer,
|
||||
modifier = Modifier.size(playerWidth.px, playerHeight.px),
|
||||
modifier = Modifier
|
||||
.size(playerWidth.px, playerHeight.px)
|
||||
.offset(y = videoOffsetY(containerH, playerHeight, vertical).px),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
|
||||
// своей половины, бинокулярно сливается в одну по центру.
|
||||
StatusPanelCompose(
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
+65
-4
@@ -15,6 +15,7 @@ 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с в кино,
|
||||
@@ -150,10 +151,43 @@ class AssistantGesturesTest {
|
||||
fun cancelStaysInConversation() {
|
||||
val decision = gestureDecision(DoubleClick(), listening)
|
||||
assertEquals(GestureDecision.CancelDictation, decision)
|
||||
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick.
|
||||
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(after))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
|
||||
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
|
||||
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
|
||||
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)
|
||||
}
|
||||
|
||||
// ————— оверлей —————
|
||||
@@ -194,6 +228,33 @@ class AssistantGesturesTest {
|
||||
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
|
||||
|
||||
@@ -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,16 +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)
|
||||
|
||||
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU)
|
||||
implementation(libs.litertlm.android)
|
||||
// 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)
|
||||
|
||||
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
||||
// 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")
|
||||
@@ -111,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")
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<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" />
|
||||
@@ -38,7 +39,8 @@
|
||||
|
||||
<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" />
|
||||
|
||||
@@ -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()} мс")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел).
|
||||
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")
|
||||
}
|
||||
}, 1_000)
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -44,12 +44,17 @@ class BtServerTransport(
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||
* 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)
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import com.google.ai.edge.litertlm.Backend
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||
import java.io.BufferedReader
|
||||
@@ -61,7 +60,11 @@ object DebugHttpServer {
|
||||
val parts = requestLine.split(" ")
|
||||
if (parts.size < 2) return
|
||||
val method = parts[0]
|
||||
val path = parts[1]
|
||||
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
|
||||
@@ -115,6 +118,103 @@ object DebugHttpServer {
|
||||
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
|
||||
@@ -139,6 +239,9 @@ object DebugHttpServer {
|
||||
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"
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
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
|
||||
@@ -22,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
|
||||
@@ -95,16 +98,31 @@ 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
|
||||
@@ -178,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 -> {
|
||||
@@ -195,13 +218,35 @@ class GlassesHub(
|
||||
is StopStt -> {
|
||||
if (msg.cancel) {
|
||||
log("stt", "распознавание отменено (очки)")
|
||||
send(connId, SttCancel())
|
||||
send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
|
||||
stt?.reset()
|
||||
} else {
|
||||
val full = stt?.finish() ?: ""
|
||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||
send(connId, SttDone(full))
|
||||
stt?.reset()
|
||||
if (full.isNotBlank()) {
|
||||
send(connId, SttDone(full))
|
||||
val phrase = full.trim()
|
||||
log("stt", "фраза в LLM (по клику): $phrase")
|
||||
onStopFullText?.invoke(phrase)
|
||||
} else {
|
||||
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
|
||||
log("stt", "фразы не было — очки в «слушаю»")
|
||||
broadcast(SttCancel(reason = SttCancel.REASON_USER))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Новый путь: очки распознали речь локально и прислали готовый текст —
|
||||
// в 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.util.Log
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог телефона.
|
||||
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
|
||||
* (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
|
||||
* (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 {
|
||||
@@ -15,4 +27,5 @@ fun log(tag: String, message: String) {
|
||||
}.onFailure {
|
||||
println(full)
|
||||
}
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestBtPermissions()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
setContent {
|
||||
ViewMateApp()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -17,24 +17,34 @@ 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
|
||||
@@ -43,8 +53,9 @@ 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,
|
||||
@@ -79,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)) }
|
||||
|
||||
@@ -114,6 +132,28 @@ class PhoneApp : Application() {
|
||||
/** Текущий локальный 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) доступен только после; ленивость —
|
||||
@@ -210,8 +250,11 @@ class PhoneApp : Application() {
|
||||
).also { toolkit = it }
|
||||
return Assistant(
|
||||
chatDao = chatDao,
|
||||
chat = { history, system -> llm.chat(history, system) },
|
||||
chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
|
||||
toolkit = tk,
|
||||
// Нативный тул-канал LiteRT: только локальная модель видит схемы
|
||||
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
|
||||
nativeTools = choice == LlmModelChoice.LOCAL,
|
||||
// Локальной модели нужно время: инициализация движка ~17с на
|
||||
// CPU + генерация. Серверная укладывается в 30с, локальной
|
||||
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
|
||||
@@ -290,10 +333,25 @@ class PhoneApp : Application() {
|
||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
server.hub.broadcast(ShowText(answer))
|
||||
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||
server.hub.broadcast(chatHistoryMsg())
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* История активной (или последней) сессии для очков: только роли user/assistant,
|
||||
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
||||
*/
|
||||
private fun chatHistoryMsg(): ChatHistoryMsg {
|
||||
val id = _activeSessionId.value ?: chatDao.latestSessionId()
|
||||
if (id == null) return ChatHistoryMsg(sessionId = null, messages = emptyList())
|
||||
val items = chatDao.messagesForSession(id)
|
||||
.filter { it.role == ChatRole.USER || it.role == ChatRole.ASSISTANT }
|
||||
.map { ChatHistoryItem(role = it.role.wire, content = it.content) }
|
||||
return ChatHistoryMsg(sessionId = id.toString(), messages = items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка текста в ассистента из чата на телефоне (не с очков).
|
||||
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
|
||||
@@ -325,43 +383,53 @@ class PhoneApp : Application() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
||||
* Лениво создаёт стриминговое распознавание (модели 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
|
||||
}
|
||||
}
|
||||
@@ -399,11 +467,36 @@ class PhoneApp : Application() {
|
||||
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)
|
||||
@@ -411,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: очки молчат (экран погас/радио спит) — пауза звука по таймауту
|
||||
@@ -485,5 +592,8 @@ class PhoneApp : Application() {
|
||||
companion object {
|
||||
lateinit var instance: PhoneApp
|
||||
private set
|
||||
|
||||
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ object PhoneConfig {
|
||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||
const val SERVER_PORT = 8080
|
||||
|
||||
/** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */
|
||||
const val OPENAI_SERVER_PORT = 8800
|
||||
|
||||
/**
|
||||
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
|
||||
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
|
||||
@@ -19,8 +22,13 @@ object PhoneConfig {
|
||||
*/
|
||||
const val USE_BLUETOOTH = true
|
||||
|
||||
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
||||
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
||||
/**
|
||||
* 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,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}") }
|
||||
}
|
||||
}
|
||||
@@ -54,15 +54,24 @@ class AgentToolkit(
|
||||
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
|
||||
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
|
||||
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
|
||||
*
|
||||
* [nativeTools] — нативный тул-канал (локальная модель, LiteRT): тулы
|
||||
* модель видит через JSON-схемы в движке, строку текстового протокола
|
||||
* TOOL_CALL в промпт НЕ кладем (иначе 2B-модель путает каналы).
|
||||
*/
|
||||
fun systemPrompt(sessionId: Long): String {
|
||||
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()
|
||||
@@ -85,6 +94,27 @@ class AgentToolkit(
|
||||
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
|
||||
}
|
||||
|
||||
// ---------- парсинг аргументов ----------
|
||||
|
||||
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
|
||||
@@ -98,31 +128,11 @@ class AgentToolkit(
|
||||
|
||||
/**
|
||||
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
||||
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
||||
* одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
|
||||
* (общая с RemoteLlmClient), метод оставлен для API/тестов.
|
||||
*/
|
||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
|
||||
val s = raw?.trim()
|
||||
if (s.isNullOrEmpty()) return emptyMap()
|
||||
val required = expected.filterNot { it.endsWith("?") }.distinct()
|
||||
val out = LinkedHashMap<String, String>()
|
||||
var bareIndex = 0
|
||||
for (field in s.split(ALTER)) {
|
||||
val f = field.trim()
|
||||
if (f.isEmpty()) continue
|
||||
val eq = f.indexOf('=')
|
||||
if (eq > 0) {
|
||||
val key = f.substring(0, eq).trim()
|
||||
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
|
||||
} else if (bareIndex < required.size) {
|
||||
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
|
||||
out[required[bareIndex++]] = f
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private val ALTER = Regex("[,&]")
|
||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
|
||||
parseToolCallArgs(raw, expected)
|
||||
|
||||
// ---------- диспач вызовов ----------
|
||||
|
||||
|
||||
@@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||
*
|
||||
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
||||
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
|
||||
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
|
||||
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
|
||||
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
|
||||
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
|
||||
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
|
||||
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
||||
* затем делает повторный вызов LLM. Невидимые системные тулы
|
||||
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
||||
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
|
||||
* пересобирается системный промпт (состояние 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,
|
||||
) {
|
||||
/**
|
||||
@@ -39,12 +42,18 @@ class Assistant(
|
||||
/** Обработать фразу в КОНКРЕТНОЙ сессии [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)
|
||||
var answer = callLlm(history, sessionId)
|
||||
var result = callLlm(history, sessionId)
|
||||
var lastToolResult: String? = null
|
||||
var visibleCalls = 0
|
||||
var llmCalls = 1
|
||||
@@ -52,31 +61,31 @@ class Assistant(
|
||||
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
|
||||
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
|
||||
while (true) {
|
||||
val parsed = parseToolCall(answer)
|
||||
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
|
||||
val toolName = parsed.name
|
||||
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 = answer)
|
||||
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
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
continue
|
||||
}
|
||||
when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) {
|
||||
when (val dispatch = toolkit.dispatch(sessionId, toolName, call.arguments)) {
|
||||
is AgentToolkit.Dispatch.Run -> {
|
||||
lastToolResult = result.result
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, result.result)
|
||||
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
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
}
|
||||
|
||||
@@ -86,29 +95,34 @@ class Assistant(
|
||||
// (переход в состояние 3 — «Активные тулсеты»).
|
||||
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
|
||||
if (llmCalls++ >= MAX_LLM_CALLS) break
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
|
||||
// чтобы пользователь получил данные, а не сырой маркер.
|
||||
val stillTool = parseToolCall(answer)
|
||||
// LLM зациклилась на тул-вызовах — отдаём последний результат тула как
|
||||
// ответ, чтобы пользователь получил данные, а не сырой маркер.
|
||||
val stillTool = result.toolCalls.firstOrNull()
|
||||
return if (stillTool != null && lastToolResult != null) {
|
||||
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
|
||||
} else {
|
||||
answer
|
||||
result.text
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
|
||||
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
|
||||
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
|
||||
?: return "Ассистент не ответил (таймаут)"
|
||||
if (!isToolCall(answer)) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
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 answer
|
||||
return result
|
||||
}
|
||||
|
||||
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
|
||||
@@ -125,23 +139,6 @@ class Assistant(
|
||||
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
|
||||
const val MAX_LLM_CALLS = 8
|
||||
|
||||
/**
|
||||
* Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
|
||||
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
|
||||
*/
|
||||
private val TOOL_CALL_RE =
|
||||
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
|
||||
|
||||
data class ParsedToolCall(val name: String, val argsRaw: String?)
|
||||
|
||||
private fun parseToolCall(answer: String): ParsedToolCall? {
|
||||
val m = TOOL_CALL_RE.find(answer) ?: return null
|
||||
val argsRaw = m.groupValues[2].trim().ifBlank { null }
|
||||
return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw)
|
||||
}
|
||||
|
||||
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
|
||||
|
||||
/** Случайное имя сессии (UUID-подобное, 8 символов). */
|
||||
private fun randomTitle(): String =
|
||||
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,106 @@ interface LlmClient {
|
||||
* [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++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +202,8 @@ class RemoteLlmClient(
|
||||
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")
|
||||
@@ -115,11 +218,34 @@ class RemoteLlmClient(
|
||||
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
||||
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
||||
}
|
||||
val text = runCatching {
|
||||
val reply = runCatching {
|
||||
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
||||
}.getOrNull()
|
||||
text?.trim() ?: ""
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import com.google.ai.edge.litertlm.Backend
|
||||
import com.google.ai.edge.litertlm.Content
|
||||
import com.google.ai.edge.litertlm.Contents
|
||||
import com.google.ai.edge.litertlm.Conversation
|
||||
import com.google.ai.edge.litertlm.ConversationConfig
|
||||
import com.google.ai.edge.litertlm.Engine
|
||||
import com.google.ai.edge.litertlm.EngineConfig
|
||||
import com.google.ai.edge.litertlm.ExperimentalApi
|
||||
import com.google.ai.edge.litertlm.ExperimentalFlags
|
||||
import com.google.ai.edge.litertlm.Message
|
||||
import 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
|
||||
@@ -75,8 +82,12 @@ object Gemma4E2B {
|
||||
* Ключ: 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 engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
|
||||
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. Авторский бинарник на
|
||||
@@ -85,10 +96,11 @@ object Gemma4E2B {
|
||||
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому
|
||||
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
|
||||
// картинке — вернём vision на CPU точечно.
|
||||
backend = Backend.GPU(),
|
||||
visionBackend = Backend.GPU(),
|
||||
audioBackend = Backend.CPU(),
|
||||
backend = LiteBackend.GPU,
|
||||
visionBackend = LiteBackend.GPU,
|
||||
audioBackend = LiteBackend.CPU,
|
||||
cacheDir = cacheDir.absolutePath,
|
||||
experimental = LiteExperimental(enableSpeculativeDecoding = true),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,53 +127,53 @@ fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
|
||||
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
|
||||
class LocalLlmPrompt(
|
||||
val systemInstruction: String?,
|
||||
val initialMessages: List<Message>,
|
||||
val incoming: Message,
|
||||
val initialMessages: List<LiteMessage>,
|
||||
val incoming: LiteMessage,
|
||||
)
|
||||
|
||||
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
|
||||
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
|
||||
.joinToString("") { it.text }
|
||||
|
||||
/**
|
||||
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
|
||||
* - USER → [Message.user], ASSISTANT → [Message.model];
|
||||
* - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model];
|
||||
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
|
||||
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет
|
||||
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж);
|
||||
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (консервативно: общая
|
||||
* с remote-клиентом текстовая конвенция; нативная роль LiteRole.TOOL —
|
||||
* задел на будущее, когда шаблон модели будет уметь родные тулзы);
|
||||
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
|
||||
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
|
||||
* - пустые content отбрасываются.
|
||||
*
|
||||
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL:
|
||||
* оно уходит в sendMessageAsync, остальное — история (initialMessages).
|
||||
* Входящее сообщение ([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<Message>()
|
||||
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 += Message.user(Contents.of(text))
|
||||
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(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 += Message.user(Contents.of("Результат тула ${msg.toolCallId ?: "?"}: $text"))
|
||||
mapped += LiteMessage.user("Результат тула ${msg.toolCallId ?: "?"}: $text")
|
||||
}
|
||||
}
|
||||
}
|
||||
val last = messages.lastOrNull { it.content.isNotBlank() }
|
||||
val incoming: Message
|
||||
val initial: List<Message>
|
||||
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 = Message.user(Contents.of("Продолжай."))
|
||||
incoming = LiteMessage.user("Продолжай.")
|
||||
}
|
||||
return LocalLlmPrompt(
|
||||
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
|
||||
@@ -170,6 +182,55 @@ fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): Lo
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
@@ -281,12 +342,15 @@ class GemmaModelDownloader(
|
||||
|
||||
/**
|
||||
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве
|
||||
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md).
|
||||
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md),
|
||||
* через KMP-библиотеку pw.binom.litert (универсальный контракт
|
||||
* RAW-инференса: владелец истории — потребитель).
|
||||
*
|
||||
* Жизненный цикл: [Engine] создаётся один раз (лениво, при первом [chat]),
|
||||
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
|
||||
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
||||
* новый Conversation (своей истории у LiteRT-LM нет — она в наших
|
||||
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским
|
||||
* новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
|
||||
* (системная инструкция + вся история + входящее) и схемы тул,
|
||||
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
|
||||
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
||||
* пользователю, падение приложения исключено.
|
||||
*
|
||||
@@ -298,96 +362,124 @@ class LocalLlmClient(
|
||||
private val cacheDir: File,
|
||||
) : LlmClient {
|
||||
|
||||
private val engineLock = Any()
|
||||
private val llmLock = Any()
|
||||
@Volatile
|
||||
private var engine: Engine? = null
|
||||
private var llm: LiteLlm? = null
|
||||
|
||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */
|
||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
|
||||
fun modelDownloaded(): Boolean = modelFile.isFile
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
|
||||
val engine = ensureEngine()
|
||||
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)}")
|
||||
val conversation: Conversation = synchronized(engineLock) {
|
||||
engine.createConversation(
|
||||
ConversationConfig(
|
||||
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) },
|
||||
initialMessages = prompt.initialMessages,
|
||||
)
|
||||
)
|
||||
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)
|
||||
}
|
||||
log("llm", "chat: conversation создан")
|
||||
try {
|
||||
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) {
|
||||
// NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт
|
||||
// emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом
|
||||
// dex null — артефакт маски $default, не аргумент.
|
||||
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
|
||||
conversation = llm.createConversation(config)
|
||||
// Поток либы сам себя не завершает (callbackFlow живёт до отмены):
|
||||
// остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
|
||||
conversation.sendStream(prompt.incoming.text)
|
||||
.onEach { delta ->
|
||||
chunks++
|
||||
val chunkText = messageChunk.text()
|
||||
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)}")
|
||||
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)} чанк/с (всего ${totalMs}мс)")
|
||||
return text.trim()
|
||||
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: отменено")
|
||||
runCatching { conversation.cancelProcess() }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
|
||||
log("llm", "chat: EXC msg=${sanitize(e.message)}")
|
||||
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
|
||||
} finally {
|
||||
runCatching { conversation.close() }
|
||||
// либа (RAW-путь) закрывает беседу сама, здесь — явный close для
|
||||
// прямого createConversation: каждый вызов — своя беседа
|
||||
runCatching { conversation?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
|
||||
fun close() {
|
||||
val eng = synchronized(engineLock) {
|
||||
val e = engine
|
||||
engine = null
|
||||
e
|
||||
val local = synchronized(llmLock) {
|
||||
val l = llm
|
||||
llm = null
|
||||
l
|
||||
}
|
||||
runCatching { eng?.close() }
|
||||
}
|
||||
|
||||
/** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */
|
||||
@OptIn(ExperimentalApi::class)
|
||||
private fun applyExperimentalFlags() {
|
||||
// NOTE 2026-08-23 (porfiry): ранее выключали спекуляцию, потому что статический
|
||||
// TopK-сэмплер при enableSpeculativeDecoding=false тянет OpenCL-драйвер,
|
||||
// которого нет в app-namespace ("Can not find OpenCL library"). Автор включил
|
||||
// её TRUE: тогда движок использует статический WebGPU-сэмплер (он на HONOR
|
||||
// работает — оригинал идёт, TTFB ~0.8с тёплый). Берём его флаг как есть.
|
||||
ExperimentalFlags.enableSpeculativeDecoding = true
|
||||
runCatching { local?.close() }
|
||||
}
|
||||
|
||||
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
|
||||
private fun ensureEngine(): Engine {
|
||||
val cached = engine
|
||||
private fun ensureLlm(): LiteLlm {
|
||||
val cached = llm
|
||||
if (cached != null) return cached
|
||||
return synchronized(engineLock) {
|
||||
val existing = engine
|
||||
return synchronized(llmLock) {
|
||||
val existing = llm
|
||||
if (existing != null) return existing
|
||||
if (!modelFile.isFile) {
|
||||
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
|
||||
@@ -395,21 +487,16 @@ class LocalLlmClient(
|
||||
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
|
||||
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
|
||||
}
|
||||
applyExperimentalFlags()
|
||||
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
|
||||
// скомпилированные шейдеры в cacheDir; если каталога нет — движок
|
||||
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
|
||||
// в кэш не пишет, поэтому работал и без mkdirs.
|
||||
cacheDir.mkdirs()
|
||||
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir))
|
||||
try {
|
||||
created.initialize()
|
||||
} catch (e: Exception) {
|
||||
runCatching { created.close() }
|
||||
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
|
||||
}
|
||||
log("llm", "локальный движок инициализирован: ${modelFile.name}")
|
||||
engine = created
|
||||
// Экспериментальные флаги (speculative decoding → WebGPU-сэмплер)
|
||||
// ушли в LiteConfig (см. [Gemma4E2B.liteConfig]).
|
||||
val created = googleLiteLlm(Gemma4E2B.liteConfig(modelFile, cacheDir))
|
||||
log("llm", "локальный движок готов: ${modelFile.name}")
|
||||
llm = created
|
||||
created
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
// Снимаем срок и освобождаем lock до распознавания — watchdog не держит
|
||||
// lock при вызове finish (иначе блокировка-в-блокировке c accept).
|
||||
val expired = lock.withLock {
|
||||
if (deadlineMs != deadline) return@withLock false
|
||||
deadlineMs = 0
|
||||
resetLocked()
|
||||
onSilence30s()
|
||||
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
|
||||
sessionPcm = sessionPcm + floats
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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(
|
||||
)
|
||||
}
|
||||
}
|
||||
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.padding(horizontal = 2.dp),
|
||||
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,6 +1,8 @@
|
||||
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
|
||||
@@ -8,9 +10,12 @@ 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
|
||||
@@ -18,7 +23,12 @@ 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
|
||||
@@ -32,6 +42,9 @@ 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
|
||||
@@ -40,6 +53,7 @@ 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
|
||||
@@ -47,7 +61,9 @@ import pw.binom.viewmate.phone.agent.Session
|
||||
/**
|
||||
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
|
||||
* локального ассистента (та же очередь, что и голос с очков). Управление
|
||||
* сессиями: новая, переключение (выпадающий список), сброс текущей.
|
||||
* сессиями: новая, переключение (выпадающий список ИЛИ ступенчатый горизонтальный
|
||||
* свайп ленты сообщений — на одну сессию за жест, без инерции; TZ-ui-four п.3),
|
||||
* сброс текущей.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatScreen() {
|
||||
@@ -55,7 +71,7 @@ fun ChatScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
|
||||
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(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) }
|
||||
@@ -68,7 +84,7 @@ fun ChatScreen() {
|
||||
val all = dao.listSessions()
|
||||
val target = activeId ?: all.firstOrNull()?.id
|
||||
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
|
||||
all to history.map { it.role to it.content }
|
||||
all to history.map(::uiMsgOf)
|
||||
}
|
||||
sessions = sess
|
||||
activeId = activeId ?: sess.firstOrNull()?.id
|
||||
@@ -89,19 +105,45 @@ fun ChatScreen() {
|
||||
}
|
||||
|
||||
// Периодическая перезагрузка сообщений из БД (ответ ассистента приходит
|
||||
// асинхронно — лента должна подхватывать его без ручного обновления).
|
||||
// асинхронно — лента должна подхватывать его без ручного обновления)
|
||||
// + списка сессий (автоименование: имя проставляется при первом сообщении).
|
||||
LaunchedEffect(activeId) {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
val id = activeId
|
||||
if (id != null) {
|
||||
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
|
||||
// Ступенчатое переключение сессий свайпом (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()
|
||||
.imePadding()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Шапка: название + управление сессиями
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
@@ -144,7 +186,7 @@ fun ChatScreen() {
|
||||
TextButton(onClick = {
|
||||
app.resetChatSession()
|
||||
activeId?.let { id ->
|
||||
val history = app.chatDao.messagesForSession(id).map { it.role to it.content }
|
||||
val history = app.chatDao.messagesForSession(id).map(::uiMsgOf)
|
||||
messages = history
|
||||
}
|
||||
}) {
|
||||
@@ -165,7 +207,31 @@ fun ChatScreen() {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Лента сообщений
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
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 = "Спроси что-нибудь — например, «сколько сейчас время?»",
|
||||
@@ -175,8 +241,11 @@ fun ChatScreen() {
|
||||
)
|
||||
} else {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
items(messages) { (role, content) ->
|
||||
MessageBubble(role = role, text = content)
|
||||
items(messages) { msg ->
|
||||
when (msg.role) {
|
||||
ChatRole.TOOL -> ToolBubble(msg)
|
||||
else -> MessageBubble(role = msg.role, text = msg.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +268,7 @@ fun ChatScreen() {
|
||||
val text = input.trim()
|
||||
if (text.isEmpty()) return@Button
|
||||
input = ""
|
||||
messages = messages + (ChatRole.USER to text)
|
||||
messages = messages + UiMsg(ChatRole.USER, text)
|
||||
app.sendToAssistant(text)
|
||||
},
|
||||
) {
|
||||
@@ -220,7 +289,9 @@ private fun MessageBubble(role: ChatRole, text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
// Базовый шрифт сообщений (TZ-ui-four п.2): крупнее, чтобы читалось
|
||||
// без напряжения (14.sp → 20.sp, ~+43%); вёртка баллонов не менялась.
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.background(
|
||||
@@ -231,3 +302,74 @@ private fun MessageBubble(role: ChatRole, text: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Элемент ленты: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (чистая функция, ветки причины «нет звука») ---
|
||||
|
||||
@@ -22,11 +22,19 @@ 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
|
||||
|
||||
/**
|
||||
@@ -164,6 +172,66 @@ 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) {
|
||||
|
||||
@@ -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,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"))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import com.google.ai.edge.litertlm.Contents
|
||||
import com.google.ai.edge.litertlm.Message
|
||||
import pw.binom.litert.LiteMessage
|
||||
import pw.binom.litert.LiteRole
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.io.path.createTempFile
|
||||
@@ -47,12 +47,12 @@ class LlmLocalTest {
|
||||
// системный промпт = SYSTEM-сообщения (системный параметр не задан)
|
||||
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
|
||||
// входящее — последнее непустое USER
|
||||
assertEquals("погоди, выключи", p.incoming.text())
|
||||
assertEquals("погоди, выключи", p.incoming.text)
|
||||
// история: user, assistant, tool-результат в «Результат тула…» формате
|
||||
assertEquals(3, p.initialMessages.size)
|
||||
assertEquals("включи свет", p.initialMessages[0].text())
|
||||
assertEquals("Свет включён.", p.initialMessages[1].text())
|
||||
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text())
|
||||
assertEquals("включи свет", p.initialMessages[0].text)
|
||||
assertEquals("Свет включён.", p.initialMessages[1].text)
|
||||
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,7 +64,7 @@ class LlmLocalTest {
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
|
||||
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
|
||||
assertEquals("привет", p.incoming.text())
|
||||
assertEquals("привет", p.incoming.text)
|
||||
assertTrue(p.initialMessages.isEmpty())
|
||||
}
|
||||
|
||||
@@ -77,10 +77,10 @@ class LlmLocalTest {
|
||||
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
assertEquals("Результат тула c7: найдено 5", p.incoming.text())
|
||||
assertEquals("Результат тула c7: найдено 5", p.incoming.text)
|
||||
// assistant с пустым текстом в историю не попадает
|
||||
assertEquals(1, p.initialMessages.size)
|
||||
assertEquals("что в каталоге?", p.initialMessages[0].text())
|
||||
assertEquals("что в каталоге?", p.initialMessages[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,7 +90,7 @@ class LlmLocalTest {
|
||||
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
assertEquals("Продолжай.", p.incoming.text())
|
||||
assertEquals("Продолжай.", p.incoming.text)
|
||||
assertEquals(2, p.initialMessages.size)
|
||||
assertTrue(p.systemInstruction == null)
|
||||
}
|
||||
@@ -105,13 +105,15 @@ class LlmLocalTest {
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
// пустое user-сообщение отброшено; входящее — «как дела»
|
||||
assertEquals(1, p.initialMessages.size)
|
||||
assertEquals("как дела", p.incoming.text())
|
||||
assertEquals("как дела", p.incoming.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageTextExtractsTextParts() {
|
||||
val m = Message.user(Contents.of("привет"))
|
||||
assertEquals("привет", m.text())
|
||||
fun liteMessageFactoriesMapRoles() {
|
||||
assertEquals(LiteMessage(LiteRole.USER, "привет"), LiteMessage.user("привет"))
|
||||
assertEquals(LiteMessage(LiteRole.MODEL, "здравствуйте"), LiteMessage.model("здравствуйте"))
|
||||
assertEquals(LiteMessage(LiteRole.SYSTEM, "ты ассистент"), LiteMessage.system("ты ассистент"))
|
||||
assertEquals(LiteMessage(LiteRole.TOOL, "результат"), LiteMessage.tool("результат"))
|
||||
}
|
||||
|
||||
// ---------- SHA-256 ----------
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
/** Тестовый [PhraseRecognizer]: любая фраза → "привет" (первая строка списка). */
|
||||
class FakePhraseRecognizer(
|
||||
private val phrases: List<String> = listOf("привет"),
|
||||
) : PhraseRecognizer {
|
||||
|
||||
override fun recognize(samples: FloatArray): String = phrases[0]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Полный текст по клику (TZ-STT): [SttStreamer] накапливает весь PCM сессии
|
||||
* (без VAD) и [SttStreamer.finish] прогоняет буфер (или хвост 120 с) одним
|
||||
* вызовом [PhraseRecognizer] с обрезанием краевой тишины, перезаписывая text.
|
||||
*/
|
||||
class FullBufferSttTest {
|
||||
|
||||
/**
|
||||
* Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
|
||||
* по одному на вызов (последний — на все лишние). [calls] читается
|
||||
* после [SttStreamer.finish] — executor к этому моменту уже отработал
|
||||
* все задачи (finish дожидается их через Future).
|
||||
*/
|
||||
private class RecordingRecognizer(vararg val responses: String) : PhraseRecognizer {
|
||||
val calls = mutableListOf<FloatArray>()
|
||||
private var count = 0
|
||||
|
||||
override fun recognize(samples: FloatArray): String {
|
||||
calls.add(samples)
|
||||
val answer = responses[minOf(count, responses.size - 1)]
|
||||
count += 1
|
||||
return answer
|
||||
}
|
||||
}
|
||||
|
||||
/** s16le-чанк: [count] сэмплов с глобальных индексов [offset] (зубчатая волна, период 32768). */
|
||||
private fun pcmChunk(offset: Int, count: Int): ByteArray {
|
||||
val out = ByteArray(count * 2)
|
||||
var i = 0
|
||||
while (i < count) {
|
||||
val u = (offset + i) % 32768
|
||||
out[2 * i] = (u and 0xff).toByte()
|
||||
out[2 * i + 1] = ((u ushr 8) and 0xff).toByte()
|
||||
i += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Точная обратная сторона [s16leToFloat] для нашей пачки (по глобальному индексу). */
|
||||
private fun expectedFloat(globalIndex: Int): Float =
|
||||
(globalIndex % 32768) / 32768f
|
||||
|
||||
private fun newStreamer(recognizer: RecordingRecognizer) = SttStreamer(
|
||||
stt = recognizer,
|
||||
onSilence30s = { },
|
||||
onAutoFinished = { },
|
||||
)
|
||||
|
||||
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
|
||||
private fun acceptShortSession(streamer: SttStreamer) {
|
||||
var off = 0
|
||||
repeat(3) {
|
||||
streamer.accept(pcmChunk(off, 1600))
|
||||
off += 1600
|
||||
}
|
||||
}
|
||||
|
||||
/** Короткая сессия (0.3 с): finish → ОДИН вызов на весь буфер (4800 сэмплов), full = текст. */
|
||||
@Test
|
||||
fun shortBufferIsRecognizedInOneCall() {
|
||||
val recognizer = RecordingRecognizer("привет")
|
||||
val streamer = newStreamer(recognizer)
|
||||
try {
|
||||
acceptShortSession(streamer)
|
||||
val full = streamer.finish()
|
||||
assertEquals(1, recognizer.calls.size, "один вызов на весь буфер, без VAD-сегментации")
|
||||
assertEquals(4800, recognizer.calls[0].size)
|
||||
assertContentEquals(FloatArray(4800) { expectedFloat(it) }, recognizer.calls[0])
|
||||
assertEquals("привет", full)
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов (текст не пуст).
|
||||
* Хвост — сплошной зубец без пауз → splitPhrases даёт одну длинную фразу (120 с),
|
||||
* которая режется страховкой на куски ≤ 30 с: 4 вызова по 30 с.
|
||||
*/
|
||||
@Test
|
||||
fun longBufferPassesLastTwoMinutes() {
|
||||
val recognizer = RecordingRecognizer("ок")
|
||||
val streamer = newStreamer(recognizer)
|
||||
try {
|
||||
val total = 130 * 16_000
|
||||
var off = 0
|
||||
while (off < total) {
|
||||
streamer.accept(pcmChunk(off, 1600))
|
||||
off += 1600
|
||||
}
|
||||
val full = streamer.finish()
|
||||
assertEquals(4, recognizer.calls.size, "120 с непрерывной речи режется на 4 фразы ≤ 30 с")
|
||||
val sumLen = recognizer.calls.sumOf { it.size }
|
||||
assertEquals(16_000 * 120, sumLen, "в сумме ровно хвост 120 с")
|
||||
recognizer.calls.forEach { assertTrue(it.size <= 30 * 16_000, "каждая фраза ≤ 30 с") }
|
||||
assertContentEquals(
|
||||
FloatArray(30 * 16_000) { expectedFloat(total - 16_000 * 120 + it) },
|
||||
recognizer.calls[0],
|
||||
"первая фраза — начало хвоста, а не начало буфера",
|
||||
)
|
||||
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Две фразы (1.5 с зуба каждая), пауза 0.7 с нулей между ними → распознаны
|
||||
* отдельно (calls.size == 2), склеены через пробел (полный буфер = "А Б").
|
||||
*/
|
||||
@Test
|
||||
fun twoPhrasesSplitByPauseRecognizedSeparately() {
|
||||
val recognizer = RecordingRecognizer("А", "Б")
|
||||
val streamer = newStreamer(recognizer)
|
||||
try {
|
||||
val phraseLen = 24_000 // 1.5 с = 75 окон (= minPhraseWindows, не обрубок)
|
||||
val pauseLen = 11_200 // 0.7 с нулей → ≥ 0.6 с, разделяет фразы
|
||||
streamer.accept(pcmChunk(0, phraseLen))
|
||||
streamer.accept(ByteArray(pauseLen * 2))
|
||||
streamer.accept(pcmChunk(phraseLen, phraseLen))
|
||||
val full = streamer.finish()
|
||||
assertEquals(2, recognizer.calls.size, "пауза 0.7 с разделила фразы")
|
||||
assertEquals("А Б", full, "фразы склеены пробелом")
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
|
||||
@Test
|
||||
fun resetThenFinishSkipsRecognizer() {
|
||||
val recognizer = RecordingRecognizer("привет")
|
||||
val streamer = newStreamer(recognizer)
|
||||
try {
|
||||
acceptShortSession(streamer)
|
||||
streamer.reset()
|
||||
val full = streamer.finish()
|
||||
assertEquals(0, recognizer.calls.size, "после reset буфер пуст — whisper не вызывается")
|
||||
assertEquals("", full)
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.viewmate.core.stt.splitPhrases
|
||||
|
||||
/**
|
||||
* Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на
|
||||
* фразы по паузам тишины (≥ 0.6 с), чтобы каждая фраза (whisper ≤ 30 с)
|
||||
* распознавалась отдельно.
|
||||
*
|
||||
* «Речь» — зубчатая волна [0,1) (RMS окна много выше порога 0.005), «тишина» —
|
||||
* нули. Порог/пауза/окно по умолчанию: окно 320 = 20 мс @ 16 кГц, пауза 0.6 с
|
||||
* (25…30 окон), объединение < 1.5 с, страховка ≤ 30 с.
|
||||
*/
|
||||
class PhraseSplitTest {
|
||||
private val win = 320
|
||||
|
||||
/** Зубчатая волна [0,1): «речь» — каждое окно даёт RMS много выше порога. */
|
||||
private fun saw(offset: Int, count: Int): FloatArray =
|
||||
FloatArray(count) { ((offset + it) % 32768) / 32768f }
|
||||
|
||||
private fun silence(count: Int): FloatArray = FloatArray(count)
|
||||
|
||||
private fun concat(vararg parts: FloatArray): FloatArray {
|
||||
var total = 0
|
||||
for (p in parts) total += p.size
|
||||
val out = FloatArray(total)
|
||||
var i = 0
|
||||
for (p in parts) {
|
||||
p.copyInto(out, i)
|
||||
i += p.size
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun hasSound(phrase: FloatArray): Boolean = phrase.any { it > 0.01f }
|
||||
|
||||
/** Две фразы (~2 с), пауза 0.7 с → разделяются; обе содержат речь. */
|
||||
@Test
|
||||
fun twoPhrasesSeparatedByLongPause() {
|
||||
val a = saw(0, 32_000)
|
||||
val pause = silence(11_200) // 0.7 с
|
||||
val b = saw(32_000, 32_000)
|
||||
val phrases = splitPhrases(concat(a, pause, b))
|
||||
assertEquals(2, phrases.size)
|
||||
phrases.forEach { assertTrue(hasSound(it), "фраза содержит звучный участок") }
|
||||
}
|
||||
|
||||
/** Пауза 0.4 с (< 0.6 с) не разделяет → одна фраза. */
|
||||
@Test
|
||||
fun shortPauseDoesNotSplit() {
|
||||
val a = saw(0, 16_000)
|
||||
val pause = silence(6_400) // 0.4 с
|
||||
val b = saw(16_000, 16_000)
|
||||
val phrases = splitPhrases(concat(a, pause, b))
|
||||
assertEquals(1, phrases.size)
|
||||
}
|
||||
|
||||
/** Тихие начала слов не режутся: порог 0.005, низкоамплитудные окна не рвут фразу. */
|
||||
@Test
|
||||
fun quietWordStartIsKeptWhole() {
|
||||
// 5 окон почти тишины (ниже порога) + резкий старт → содержимое остаётся одной фразой,
|
||||
// тихие окна начала сохраняются в фразе (не выброшены).
|
||||
val quietHead = silence(5 * win)
|
||||
val loudStart = FloatArray(win) { 0.05f }
|
||||
val speech = saw(0, win * 100) // 100 окон речи (2 с) — не объединяется как обрубок
|
||||
val samples = concat(quietHead, loudStart, speech)
|
||||
val phrases = splitPhrases(samples)
|
||||
assertEquals(1, phrases.size)
|
||||
assertTrue(phrases[0].size >= (5 + 1 + 100) * win, "тихие окна начала сохранены")
|
||||
assertTrue(hasSound(phrases[0]), "фраза содержит речь")
|
||||
}
|
||||
|
||||
/** Всё тишина → пустой список. */
|
||||
@Test
|
||||
fun allSilenceReturnsEmpty() {
|
||||
assertEquals(0, splitPhrases(silence(48_000)).size)
|
||||
}
|
||||
|
||||
/** Пустой вход → пустой список. */
|
||||
@Test
|
||||
fun emptyInputReturnsEmpty() {
|
||||
assertEquals(0, splitPhrases(FloatArray(0)).size)
|
||||
}
|
||||
|
||||
/** Фраза 31 с → разрезана на куски, каждый ≤ 30 с. */
|
||||
@Test
|
||||
fun phraseLongerThanThirtySecondsIsSplit() {
|
||||
val long = saw(0, 31 * 16_000)
|
||||
val phrases = splitPhrases(long)
|
||||
assertEquals(2, phrases.size)
|
||||
phrases.forEach { assertTrue(it.size <= 30 * 16_000, "кусок ≤ 30 с") }
|
||||
}
|
||||
|
||||
/** Две короткие фразы (0.8 с + 0.9 с, пауза 0.7 с) объединяются в одну. */
|
||||
@Test
|
||||
fun shortPhrasesAreMerged() {
|
||||
val a = saw(0, 12_800) // 0.8 с = 40 окон (< 75)
|
||||
val pause = silence(11_200)
|
||||
val b = saw(12_800, 14_400) // 0.9 с = 45 окон (< 75)
|
||||
val phrases = splitPhrases(concat(a, pause, b))
|
||||
assertEquals(1, phrases.size)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
|
||||
class S16ToFloatTest {
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import pw.binom.voice.vad.VadInstance
|
||||
|
||||
/**
|
||||
* Тестовый стриминговый VAD на реальном Silero (JVM, ONNX через [VadInstance]).
|
||||
* Постпроцесс — поведенческий паритет с боевой SherpaStreamingVad:
|
||||
* threshold 0.5; minSilence = minSpeech = 0.25 с = 4000 сэмплов;
|
||||
* maxSpeech = 20 с = 320 000 сэмплов; окно 512 сэмплов.
|
||||
*
|
||||
* ВНИМАНИЕ: `buf` хранит все сэмплы с начала стрима (60 с ≈ 3.9 МБ) —
|
||||
* для тестов допустимо, для боевого кода не предлагать.
|
||||
*/
|
||||
class SileroStreamingVad : StreamingVad {
|
||||
|
||||
private val vad = VadInstance(16000)
|
||||
|
||||
private var inSpeech = false
|
||||
private var segStart = 0
|
||||
private var lastSpeechEnd = 0
|
||||
private var total = 0 // обработано сэмплов
|
||||
private val buf = ArrayList<Float>() // все сэмплы от начала, для среза сегментов
|
||||
private val queue = ArrayDeque<VadSegment>()
|
||||
|
||||
override val windowSizeSamples: Int = 512
|
||||
|
||||
override fun acceptWindow(window: FloatArray) {
|
||||
require(window.size == 512) { "Окно должно быть 512 сэмплов, подано " + window.size }
|
||||
val p = vad.processingWindow(window)
|
||||
val s = total
|
||||
val sEnd = s + 512
|
||||
for (w in window) {
|
||||
buf.add(w)
|
||||
}
|
||||
total = sEnd
|
||||
if (p >= 0.5f) {
|
||||
if (!inSpeech) {
|
||||
inSpeech = true
|
||||
segStart = s
|
||||
}
|
||||
lastSpeechEnd = sEnd
|
||||
// maxSpeech: 20 с — сегмент принудительно закрывается.
|
||||
if (lastSpeechEnd - segStart >= 320_000) {
|
||||
emit()
|
||||
inSpeech = false
|
||||
}
|
||||
} else if (inSpeech && sEnd - lastSpeechEnd >= 4000) {
|
||||
// minSilence: 0.25 с тишины после речи — сегмент готов.
|
||||
if (lastSpeechEnd - segStart >= 4000) {
|
||||
emit()
|
||||
}
|
||||
inSpeech = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun popSegment(): VadSegment? {
|
||||
if (queue.isEmpty()) return null
|
||||
return queue.removeFirst()
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
if (inSpeech && lastSpeechEnd - segStart >= 4000) {
|
||||
emit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { vad.close() }
|
||||
}
|
||||
|
||||
/** Готовый сегмент: срез из общего буфера [segStart, lastSpeechEnd). */
|
||||
private fun emit() {
|
||||
val len = lastSpeechEnd - segStart
|
||||
val seg = VadSegment(segStart, FloatArray(len) { buf[segStart + it] })
|
||||
queue.addLast(seg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* «Чистый» тест распознавания: реальный Silero VAD (ONNX через vad-jvm) +
|
||||
* реальная аудио-фикстура voice-samples/masha-anton.wav.
|
||||
* Без pw.binom.viewmate в classpath — только библиотека VAD и тестовый код.
|
||||
*/
|
||||
class SileroVadRealAudioTest {
|
||||
|
||||
@Test
|
||||
fun segmentsOnRealAudio() {
|
||||
val vad = SileroStreamingVad()
|
||||
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||
val n = pcm.size / 2 // сэмплов 16 кГц
|
||||
assertTrue(n >= 512, "PCM фикстуры пуст")
|
||||
|
||||
// Весь PCM → окнами по 512 (хвост < 512 дополняем нулями — тишиной).
|
||||
val total = ((n + 511) / 512) * 512 // обработано сэмплов на VAD
|
||||
val segments = mutableListOf<VadSegment>()
|
||||
for (i in 0 until total step 512) {
|
||||
val window = FloatArray(512)
|
||||
for (j in 0 until 512) {
|
||||
val idx = i + j
|
||||
if (idx < n) {
|
||||
val lo = pcm[idx * 2].toInt() and 0xff
|
||||
val hi = pcm[idx * 2 + 1].toInt() and 0xff
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
window[j] = s / 32768f
|
||||
}
|
||||
}
|
||||
vad.acceptWindow(window)
|
||||
var seg = vad.popSegment()
|
||||
while (seg != null) {
|
||||
segments.add(seg)
|
||||
seg = vad.popSegment()
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(segments.size >= 1, "сегментов не найдено (VAD не нашёл речь в 60-секундной фикстуре)")
|
||||
var speechSamples = 0
|
||||
for (seg in segments) {
|
||||
assertTrue(seg.startSample >= 0, "start < 0 в сегменте ${seg.startSample}")
|
||||
assertTrue(
|
||||
seg.startSample + seg.samples.size <= total,
|
||||
"сегмент выходит за конец потока: start=${seg.startSample}, len=${seg.samples.size}, total=$total",
|
||||
)
|
||||
speechSamples += seg.samples.size
|
||||
}
|
||||
assertTrue(
|
||||
speechSamples in 5000..500_000,
|
||||
"суммарная речь ${speechSamples} сэмплов вне ожидаемого [5000..500000] " +
|
||||
"(${speechSamples / 16000.0}s вне 0.3–31с), сегментов=${segments.size}",
|
||||
)
|
||||
|
||||
vad.flush()
|
||||
assertTrue(vad.popSegment() == null, "после flush() остались сегменты")
|
||||
vad.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.viewmate.core.stt.trimEdges
|
||||
|
||||
/**
|
||||
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
|
||||
* внутренние паузы сохраняются, отступ не выходит за границы.
|
||||
*
|
||||
* Окно 320 сэмплов = 20 мс @ 16 кГц; отступ [padWindows]=15 → 4800 сэмплов = 300 мс.
|
||||
* «Речь» — сэмпл 0.5f (RMS окна 0.5 ≥ порог 0.01), «тишина» — 0.005f (ниже порога).
|
||||
*/
|
||||
class TrimEdgesTest {
|
||||
private val win = 320
|
||||
private val pad = 15 * 320
|
||||
|
||||
/** Массив из [count] сэмплов заданного уровня. */
|
||||
private fun fill(count: Int, level: Float): FloatArray = FloatArray(count) { level }
|
||||
|
||||
private fun concat(vararg parts: FloatArray): FloatArray {
|
||||
var total = 0
|
||||
for (p in parts) total += p.size
|
||||
val out = FloatArray(total)
|
||||
var i = 0
|
||||
for (p in parts) {
|
||||
p.copyInto(out, i)
|
||||
i += p.size
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Краевая тишина (100 окон до и после речи в 3 окна) убирается, речь сохраняется. */
|
||||
@Test
|
||||
fun edgeSilenceIsTrimmed() {
|
||||
val lead = fill(100 * win, 0.005f)
|
||||
val voice = fill(3 * win, 0.5f)
|
||||
val tail = fill(100 * win, 0.005f)
|
||||
val samples = concat(lead, voice, tail)
|
||||
|
||||
val res = trimEdges(samples)
|
||||
|
||||
val start = 100 * win - pad
|
||||
val stop = 103 * win + pad
|
||||
assertContentEquals(samples.copyOfRange(start, stop), res, "краевая тишина обрезана, речь сохранена")
|
||||
}
|
||||
|
||||
/** Внутренняя пауза (197 окон тишины между двумя речевыми блоками) полностью сохраняется. */
|
||||
@Test
|
||||
fun internalPauseIsPreserved() {
|
||||
val lead = fill(100 * win, 0.005f)
|
||||
val voice1 = fill(3 * win, 0.5f)
|
||||
val pause = fill(197 * win, 0.005f)
|
||||
val voice2 = fill(3 * win, 0.5f)
|
||||
val tail = fill(100 * win, 0.005f)
|
||||
val samples = concat(lead, voice1, pause, voice2, tail)
|
||||
|
||||
val res = trimEdges(samples)
|
||||
|
||||
val start = 100 * win - pad
|
||||
val stop = 303 * win + pad
|
||||
assertContentEquals(samples.copyOfRange(start, stop), res, "внутренняя пауза не выброшена")
|
||||
}
|
||||
|
||||
/** Пустой вход → пустой выход (без ошибок). */
|
||||
@Test
|
||||
fun emptyInputReturnsEmpty() {
|
||||
assertEquals(0, trimEdges(FloatArray(0)).size)
|
||||
}
|
||||
|
||||
/** Всё-тишина (ниже порога) → пустой массив. */
|
||||
@Test
|
||||
fun allSilenceReturnsEmpty() {
|
||||
val res = trimEdges(FloatArray(100 * win) { 0.005f })
|
||||
assertEquals(0, res.size, "звучных окон нет — результат пуст")
|
||||
}
|
||||
|
||||
/** Отступ не выходит за границы: речь у краёв короткого буфера → весь массив без исключений. */
|
||||
@Test
|
||||
fun paddingClampsToBounds() {
|
||||
val size = 2000
|
||||
val samples = FloatArray(size)
|
||||
for (i in 0 until win) samples[i] = 0.5f
|
||||
for (i in size - win until size) samples[i] = 0.5f
|
||||
|
||||
val res = trimEdges(samples)
|
||||
|
||||
assertContentEquals(samples.copyOfRange(0, size), res, "clamp к границам, без выхода за массив")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Минимальный загрузчик RIFF/WAVE для тестов: s16le, 16 кГц, mono.
|
||||
* (Свой, а не WavReader из main — тот отдаёт FloatArray, а нужно как раз
|
||||
* сырые s16le-байты PCM.)
|
||||
*/
|
||||
object WavPcm {
|
||||
|
||||
/** Абсолютный путь к фикстуре voice-samples/masha-anton.wav (корень репо). */
|
||||
fun fixture(): File {
|
||||
// working dir юнит-теста gradle — каталог модуля (app-phone) или корень;
|
||||
// ищем voice-samples/ вверх от user.dir.
|
||||
var dir = File(System.getProperty("user.dir")).absoluteFile
|
||||
while (true) {
|
||||
val cand = File(dir, "voice-samples/masha-anton.wav")
|
||||
if (cand.isFile) return cand
|
||||
dir = dir.parentFile ?: break
|
||||
}
|
||||
return File("voice-samples/masha-anton.wav").absoluteFile
|
||||
}
|
||||
|
||||
/** PCM s16le, 16 кГц, mono. Если формат не тот — fail(). */
|
||||
fun loadPcm(path: String): ByteArray {
|
||||
val f = File(path)
|
||||
check(f.isFile) { "WAV не найден: $path" }
|
||||
val b = f.readBytes()
|
||||
check(b.size > 44) { "WAV: файл короче заголовка" }
|
||||
fun str(off: Int, len: Int) = String(b.copyOfRange(off, off + len))
|
||||
check(str(0, 4) == "RIFF") { "WAV: не RIFF" }
|
||||
check(str(8, 4) == "WAVE") { "WAV: не WAVE" }
|
||||
fun i16(off: Int) = ByteBuffer.wrap(b, off, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
|
||||
fun i32(off: Int) = ByteBuffer.wrap(b, off, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
||||
|
||||
var off = 12
|
||||
var audioFormat = 0
|
||||
var channels = 0
|
||||
var sampleRate = 0
|
||||
var bitsPerSample = 0
|
||||
var dataOff = -1
|
||||
var dataLen = 0
|
||||
while (off + 8 <= b.size) {
|
||||
val id = str(off, 4)
|
||||
val len = i32(off + 4)
|
||||
when (id) {
|
||||
"fmt " -> {
|
||||
audioFormat = i16(off + 8)
|
||||
channels = i16(off + 10)
|
||||
sampleRate = i32(off + 12)
|
||||
bitsPerSample = i16(off + 22)
|
||||
}
|
||||
"data" -> {
|
||||
dataOff = off + 8
|
||||
dataLen = len
|
||||
break
|
||||
}
|
||||
}
|
||||
off += 8 + len + (len and 1) // чанки выравниваются по чётным байтам
|
||||
}
|
||||
check(audioFormat == 1) { "WAV: не PCM (format=$audioFormat)" }
|
||||
check(channels == 1) { "WAV: каналов=$channels, ожидался mono" }
|
||||
check(sampleRate == 16000) { "WAV: частота=$sampleRate, ожидалась 16000" }
|
||||
check(bitsPerSample == 16) { "WAV: битов/сэмпл=$bitsPerSample, ожидалось 16" }
|
||||
require(dataOff >= 0) { "WAV: нет чанка data" }
|
||||
val end = minOf(b.size, dataOff + dataLen)
|
||||
return b.copyOfRange(dataOff, end)
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,13 @@ allprojects {
|
||||
}
|
||||
group = "pw.binom.viewmate"
|
||||
}
|
||||
|
||||
// Собери оба приложения и подпиши ключом Binom (v2+v3) одним шагом.
|
||||
// Кестор/креды берутся из скилла android-sign (см. signingConfigs в build.gradle.kts каждого app-модуля);
|
||||
// без кестора release соберётся без подписи.
|
||||
tasks.register("assembleSigned") {
|
||||
group = "build"
|
||||
description = "Собирает app-phone и app-glasses release-APK, подписанные ключом Binom (v2+v3)."
|
||||
dependsOn(":app-phone:assembleRelease", ":app-glasses:assembleRelease")
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ androidx-activity-compose = "1.10.1"
|
||||
exoplayer = "2.17.1"
|
||||
media3 = "1.6.1"
|
||||
koog = "1.1.1"
|
||||
litertlm-android = "0.14.0"
|
||||
litert-google = "5"
|
||||
|
||||
[libraries]
|
||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||
@@ -24,9 +24,15 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
|
||||
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
|
||||
# LiteRT LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
|
||||
# litertlm-android 0.14.0 нужен на kotlinx-coroutines 1.11.0 (shared версия поднята)
|
||||
litertlm-android = { module = "com.google.ai.edge.litertlm:litertlm-android", version.ref = "litertlm-android" }
|
||||
# LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
|
||||
# KMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp):
|
||||
# универсальный контракт (LiteLlm/LiteRequest — RAW-инференс, владелец истории —
|
||||
# потребитель) + нативный тул-слой (LiteTool/LiteToolCall, tag 5) + google-бэкенд
|
||||
# над официальным litertlm:0.16.1 (транзитивно).
|
||||
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert-google" }
|
||||
# Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются
|
||||
# приложением напрямую; в litert-google он runtime-only, поэтому явный.
|
||||
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert-google" }
|
||||
|
||||
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import java.io.File
|
||||
|
||||
class FileLogSpool(
|
||||
dir: String,
|
||||
maxFiles: Int,
|
||||
) : LogSpool {
|
||||
private val filesDir: File = File(dir).also { if (!it.exists()) it.mkdirs() }
|
||||
private val limit = maxFiles
|
||||
|
||||
/** Сериал следующих файлов: продолжение после старейшего существующего. */
|
||||
private var seq: Long = currentFiles().mapNotNull { it.seqNumber() }.maxOrNull() ?: 0L
|
||||
|
||||
override fun write(batch: LogBatch) {
|
||||
seq += 1
|
||||
File(filesDir, "$PREFIX$seq.json").writeText(LogBatch.encodeBatch(batch))
|
||||
trim()
|
||||
}
|
||||
|
||||
override fun peek(): LogBatch? {
|
||||
var file = oldestFile()
|
||||
while (file != null) {
|
||||
val batch = decodeQuiet(file)
|
||||
if (batch != null) return batch
|
||||
file.delete() // битый файл не должен затыкать очередь
|
||||
file = oldestFile()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun take(): LogBatch? {
|
||||
var file = oldestFile()
|
||||
while (file != null) {
|
||||
val batch = decodeQuiet(file)
|
||||
file.delete()
|
||||
if (batch != null) return batch
|
||||
file = oldestFile()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun pendingCount(): Int = currentFiles().size
|
||||
|
||||
private fun oldestFile(): File? = currentFiles().minByOrNull { it.seqNumber() }
|
||||
|
||||
private fun currentFiles(): List<File> = filesDir
|
||||
.listFiles { f -> f.isFile && f.name.startsWith(PREFIX) && f.name.endsWith(".json") }
|
||||
?.toList()
|
||||
?: emptyList()
|
||||
|
||||
private fun File.seqNumber(): Long =
|
||||
name.substringAfter(PREFIX).removeSuffix(".json").toLongOrNull() ?: 0L
|
||||
|
||||
private fun decodeQuiet(file: File): LogBatch? =
|
||||
runCatching { LogBatch.decodeBatch(file.readText()) }.getOrNull()
|
||||
|
||||
/** Потолок: выбросить старейшие файлы сверх [limit]. */
|
||||
private fun trim() {
|
||||
val files = currentFiles().sortedBy { it.seqNumber() }
|
||||
if (files.size <= limit) return
|
||||
files.take(files.size - limit).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFIX = "logspool-"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
|
||||
actual class LogLock {
|
||||
private val delegate = ReentrantLock()
|
||||
|
||||
actual fun <T> withLock(action: () -> T): T {
|
||||
delegate.lock()
|
||||
return try {
|
||||
action()
|
||||
} finally {
|
||||
delegate.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
actual class SystemClock : LogClock {
|
||||
override fun nowMillis(): Long = System.currentTimeMillis()
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
package pw.binom.viewmate.core
|
||||
|
||||
/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог общего слоя (KMP): без java.time, просто тег + сообщение.
|
||||
*
|
||||
* Приложение подключает свой коллектор через [logCollector] (в onCreate),
|
||||
* и каждый вызов [log] попадает в него (дальше — в спул / на сервер / Loki).
|
||||
* По умолчанию коллектор не задан — лог только в println.
|
||||
*/
|
||||
var logCollector: BatchingLogCollector? = null
|
||||
|
||||
fun log(tag: String, message: String) {
|
||||
println("[view-mate] $tag $message")
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Окно приёма Loki: записи старше этого возраста (или из будущего) отбрасываем. */
|
||||
private const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* «Абстрактный коллектор» лог-пачек: собирает записи в батчи и по времени
|
||||
* шлёт их «вниз» через [sink] (абстракция следующего коллектора).
|
||||
*
|
||||
* Смысл:
|
||||
* - [record]/[recordBatch] — синхронно, с любой нитки: запись в буфер (лок),
|
||||
* при переполнении [maxBatchEntries] — немедленный flush;
|
||||
* - таймер (период [flushIntervalMs]) — собирает буфер в [LogBatch] и шлёт в [sink];
|
||||
* - «не получилось послать» ([LogSink.send] = false / бросил) — пачка
|
||||
* **прихраняется в [spool]** и на каждом следующем тике ретраится ДО новых
|
||||
* пачек (FIFO: старое раньше);
|
||||
* - «чужие» записи (например, логи очков, пришедшие на телефон) — [recordBatch]
|
||||
* с сохранением их [LogEntry.source]/[LogEntry.ts].
|
||||
*
|
||||
* Поток: буфер — из любых ниток (под [LogLock]); спул и отправка — только
|
||||
* таймер-корутина (однопоточно).
|
||||
*/
|
||||
class BatchingLogCollector(
|
||||
/** Устройство-носитель коллектора: "glasses" / "phone". */
|
||||
val device: String,
|
||||
/** «Следующий» коллектор: куда шлём пачки. */
|
||||
private val sink: LogSink,
|
||||
/** Куда прихраним не отправленные пачки (retry позже). */
|
||||
private val spool: LogSpool,
|
||||
/** Часы для меток времени записей. */
|
||||
private val clock: LogClock,
|
||||
/** Период отправки пачек, мс. */
|
||||
private val flushIntervalMs: Long = 30_000L,
|
||||
/** Лимит записей в одной пачке — превышение триггерит немедленный flush. */
|
||||
private val maxBatchEntries: Int = 500,
|
||||
) {
|
||||
private val buffer = ArrayList<LogEntry>()
|
||||
private val lock = LogLock()
|
||||
|
||||
/** Потолок буфера в памяти (страховка, если sink долго не принимал). */
|
||||
private val maxBuffer: Int = maxBatchEntries * 20
|
||||
|
||||
@Volatile
|
||||
private var timerJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
/** Записать свою лог-строку (синхронно, с любой нитки). */
|
||||
fun record(tag: String, message: String) {
|
||||
recordBatch(listOf(LogEntry(clock.nowMillis(), tag, message, device)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Прибавить «чужие» записи (готовые [LogEntry] — со своими ts/source):
|
||||
* телефон так пересылает логи очков.
|
||||
*/
|
||||
fun recordBatch(entries: List<LogEntry>) {
|
||||
if (entries.isEmpty()) return
|
||||
val overflowed = lock.withLock {
|
||||
buffer.addAll(entries)
|
||||
val drop = buffer.size - maxBuffer
|
||||
if (drop > 0) repeat(drop) { buffer.removeAt(0) }
|
||||
buffer.size >= maxBatchEntries
|
||||
}
|
||||
if (overflowed) scope?.launch { flush() }
|
||||
}
|
||||
|
||||
/** Запустить таймер: каждые [flushIntervalMs] — flush (спул-ретраи + новый батч). */
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (timerJob != null) return
|
||||
this.scope = scope
|
||||
timerJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(flushIntervalMs)
|
||||
runCatching { flush() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Остановить таймер (буфер/спул не трогаются — дошлёт следующий запуск). */
|
||||
fun stop() {
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
scope = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Цикл отправки:
|
||||
* 1) ретраи спула — только старейшая пачка; не слано — ждём следующий тик
|
||||
* (новый батч не «перескакивает» через неё, порядок сохраняется);
|
||||
* 2) новый батч из буфера; не слано — прихраняем в [spool].
|
||||
*/
|
||||
suspend fun flush() {
|
||||
// Ретраи спула: выгребаем ВСЕ готовые пачки за тик (FIFO), а не одну.
|
||||
// Записи с «неприемлемым» таймстемпом (старше окна Loki ~7 дней или из
|
||||
// будущего) отбрасываем — иначе одна такая пачка топит весь пуш (Loki
|
||||
// отвечает 400 на весь batch) и спул никогда не чистится.
|
||||
var spooled = spool.peek()
|
||||
while (spooled != null) {
|
||||
val cleaned = clean(spooled)
|
||||
val ok = if (cleaned.entries.isEmpty()) true else sendBatch(cleaned)
|
||||
if (ok) {
|
||||
spool.take()
|
||||
spooled = spool.peek()
|
||||
} else {
|
||||
// спул не ушёл — буфер не трогаем, ждём следующий тик
|
||||
return
|
||||
}
|
||||
}
|
||||
val batch = drainBuffer() ?: return
|
||||
val cleaned = clean(batch)
|
||||
if (cleaned.entries.isNotEmpty()) {
|
||||
if (!sendBatch(cleaned)) spool.write(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбросить записи вне окна приёма Loki: старше [RETENTION_MS] или из будущего.
|
||||
* Такие записи Loki всё равно отверг бы всем батчем (HTTP 400), поэтому их
|
||||
* безопасно выкинуть — они не могут попасть в хранилище.
|
||||
*/
|
||||
private fun clean(batch: LogBatch): LogBatch {
|
||||
val now = clock.nowMillis()
|
||||
val min = now - RETENTION_MS
|
||||
val max = now + 3_600_000L
|
||||
val kept = batch.entries.filter { it.ts in min..max }
|
||||
return if (kept.size == batch.entries.size) batch else batch.copy(entries = kept)
|
||||
}
|
||||
|
||||
private suspend fun sendBatch(batch: LogBatch): Boolean =
|
||||
runCatching { sink.send(batch) }.getOrDefault(false)
|
||||
|
||||
/** Взять все накопленные записи в одну пачку (null — пусто). */
|
||||
private fun drainBuffer(): LogBatch? = lock.withLock {
|
||||
if (buffer.isEmpty()) return@withLock null
|
||||
val batch = LogBatch(device, buffer.toList())
|
||||
buffer.clear()
|
||||
batch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import pw.binom.viewmate.core.media.defaultHttpClient
|
||||
|
||||
/**
|
||||
* [LogSink] по HTTP: POST JSON-пачки на [url] (заголовок X-API-Key, если [apiKey] задан).
|
||||
* 2xx — принято (true); любой другой ответ/сетевая ошибка — false (пачка уйдёт в спул).
|
||||
*/
|
||||
class HttpLogSink(
|
||||
private val client: HttpClient,
|
||||
private val url: String,
|
||||
private val apiKey: String? = null,
|
||||
) : LogSink {
|
||||
override suspend fun send(batch: LogBatch): Boolean {
|
||||
val response: HttpResponse = runCatching {
|
||||
client.post(url) {
|
||||
if (apiKey != null) header("X-API-Key", apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(LogBatch.encodeBatch(batch))
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return response.status.isSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
/** Готовый HTTP-лог-синк со своим клиентом: вызывающая сторона не знает про Ktor. */
|
||||
fun httpLogSink(url: String, apiKey: String? = null): LogSink =
|
||||
HttpLogSink(defaultHttpClient(), url, apiKey)
|
||||
@@ -0,0 +1,44 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Одна лог-запись.
|
||||
* [ts] — часы устройства-источника, epoch-мс.
|
||||
* [tag] — подсистема (stt, hub, mc, …).
|
||||
* [source] — кто сгенерировал ("glasses" / "phone") — чтобы телефон мог пересылать
|
||||
* логи очков на сервер с сохранением источника.
|
||||
*/
|
||||
@Serializable
|
||||
data class LogEntry(
|
||||
val ts: Long,
|
||||
val tag: String,
|
||||
val message: String,
|
||||
val source: String = "unknown",
|
||||
)
|
||||
|
||||
/**
|
||||
* Пачка логов — единица передачи в «следующий» коллектор.
|
||||
* [device] — кто шлёт эту пачку (устройство-коллектор).
|
||||
*/
|
||||
@Serializable
|
||||
data class LogBatch(
|
||||
val device: String,
|
||||
val entries: List<LogEntry>,
|
||||
) {
|
||||
companion object {
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/** Пачка → JSON-строка (файл спула). */
|
||||
fun encodeBatch(batch: LogBatch): String = json.encodeToString(serializer(), batch)
|
||||
|
||||
/** JSON-строка → пачка; некорректный/битый текст — null. */
|
||||
fun decodeBatch(text: String): LogBatch? = runCatching {
|
||||
json.decodeFromString(serializer(), text)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/** Часы для лог-записей: у common-кода нет java.time — устройство-носитель подаёт свои. */
|
||||
fun interface LogClock {
|
||||
fun nowMillis(): Long
|
||||
}
|
||||
|
||||
/** Стенговые (wall-clock) часы — JVM-реализация (Android/JVM: `System.currentTimeMillis`). */
|
||||
expect class SystemClock() : LogClock
|
||||
|
||||
/** Зависанный (fixed) ход времени для юнит-тестов. */
|
||||
class FixedClock(private var now: Long = 0L) : LogClock {
|
||||
override fun nowMillis(): Long = now
|
||||
|
||||
/** Продвинуть «время» на [ms]. */
|
||||
fun advance(ms: Long) {
|
||||
now += ms
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/**
|
||||
* Лока (thread-safe) для буфера коллектора: KMP-common не даёт нативных лок-ов —
|
||||
* каждая платформа подставляет свою (Android/JVM: `java.util.concurrent.locks.ReentrantLock`).
|
||||
*/
|
||||
expect class LogLock() {
|
||||
/** Выполнить [action] под локом; вернуть результат. */
|
||||
fun <T> withLock(action: () -> T): T
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/**
|
||||
* Абстракция «следующего» получателя лог-пачек (сервер у телефона, сам телефон
|
||||
* у очков, чёрная дыра в тестах). Коллектор не знает про транспорт/протокол —
|
||||
* только про результат отправки.
|
||||
*/
|
||||
fun interface LogSink {
|
||||
/**
|
||||
* Отправить [batch] вниз по цепочке.
|
||||
* @return `true` — принято (пачка потреблена); `false` — не принято
|
||||
* (коллектор положит пачку в спул и попробует позже).
|
||||
*/
|
||||
suspend fun send(batch: LogBatch): Boolean
|
||||
}
|
||||
|
||||
/** LogSink, который пачки проглатывает (логирование отключено / нет цели). */
|
||||
object NoopLogSink : LogSink {
|
||||
override suspend fun send(batch: LogBatch): Boolean = true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/**
|
||||
* Хранилище не отправленных пачек (спул): «не удалось послать → прихраним у себя
|
||||
* и позже попробуем ещё». FIFO: первым ретраится самая старая пачка.
|
||||
*
|
||||
* Доступ — из одного потока (таймер-корутина коллектора): peek/take на каждом
|
||||
* тике, write — при неудачной отправке.
|
||||
*/
|
||||
interface LogSpool {
|
||||
/** Добавить [batch] в конец очереди. */
|
||||
fun write(batch: LogBatch)
|
||||
|
||||
/** Самая старая пачка без удаления (null — пусто). */
|
||||
fun peek(): LogBatch?
|
||||
|
||||
/** Самая старая пачка с удалением (null — пусто). */
|
||||
fun take(): LogBatch?
|
||||
|
||||
/** Сколько пачек ждёт отправки. */
|
||||
fun pendingCount(): Int
|
||||
}
|
||||
|
||||
/**
|
||||
* In-память спул без персистентности (тесты, «прихранил в процессе»):
|
||||
* очередь с потолком [maxBatches] — при переполнении выбрасывается старейшая.
|
||||
*/
|
||||
class InMemoryLogSpool(private val maxBatches: Int = 32) : LogSpool {
|
||||
private val queue = ArrayDeque<LogBatch>()
|
||||
|
||||
override fun write(batch: LogBatch) {
|
||||
if (queue.size >= maxBatches) queue.removeFirst()
|
||||
queue.addLast(batch)
|
||||
}
|
||||
|
||||
override fun peek(): LogBatch? = queue.firstOrNull()
|
||||
|
||||
override fun take(): LogBatch? = queue.removeFirstOrNull()
|
||||
|
||||
override fun pendingCount(): Int = queue.size
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import pw.binom.viewmate.core.media.defaultHttpClient
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Коллектор в Loki (Grafana): POST JSON в [LokiConfig.url] (Basic-auth).
|
||||
*
|
||||
* Важное: instance каждого stream'а = [LogEntry.source] записи ("phone"/"glasses"),
|
||||
* то есть в Loki сразу видно, откуда лог — с телефона или с очков. Пачка на
|
||||
* телефоне может содержать и то, и другое (телефон пересылает логи очков),
|
||||
* поэтому группируем по source и шлём несколько stream'ов одним запросом.
|
||||
*/
|
||||
class LokiLogSink(
|
||||
private val client: HttpClient,
|
||||
private val config: LokiConfig = LokiConfig(),
|
||||
) : LogSink {
|
||||
|
||||
private val authHeader = "Basic " + base64("${config.user}:${config.password}")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun send(batch: LogBatch): Boolean {
|
||||
if (batch.entries.isEmpty()) return true
|
||||
val payload = buildLokiPush(batch.entries, config.job)
|
||||
val response: HttpResponse = runCatching {
|
||||
client.post(config.url) {
|
||||
header("Authorization", authHeader)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(json.encodeToString(LokiPush.serializer(), payload))
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return response.status.isSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
/** Параметры Loki. Логин/пароль/адрес зашиты по умолчанию (см. запрос пользователя). */
|
||||
data class LokiConfig(
|
||||
val url: String = "https://loki.binom.pw/loki/api/v1/push",
|
||||
val user: String = "loki-ingest",
|
||||
val password: String = "q0OH8na60bE_KhGc6c1YOsUuFPrLjw2K",
|
||||
val job: String = "view-mate",
|
||||
)
|
||||
|
||||
/** Готовый Loki-синк со своим клиентом: вызывающая сторона не знает про Ktor/Basic-auth. */
|
||||
fun lokiLogSink(config: LokiConfig = LokiConfig()): LogSink =
|
||||
LokiLogSink(defaultHttpClient(), config)
|
||||
|
||||
@Serializable
|
||||
internal data class LokiStream(val stream: Map<String, String>, val values: List<List<String>>)
|
||||
|
||||
@Serializable
|
||||
internal data class LokiPush(val streams: List<LokiStream>)
|
||||
|
||||
internal fun buildLokiPush(entries: List<LogEntry>, job: String): LokiPush {
|
||||
val streams = entries
|
||||
.groupBy { it.source.ifBlank { "phone" } }
|
||||
.map { (source, es) ->
|
||||
val values = es
|
||||
.map { e -> listOf((e.ts * 1_000_000).toString(), "${e.tag}: ${e.message}") }
|
||||
.sortedBy { it[0] }
|
||||
LokiStream(stream = mapOf("job" to job, "instance" to source), values = values)
|
||||
}
|
||||
return LokiPush(streams = streams)
|
||||
}
|
||||
|
||||
private val B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
/** Минимальный base64 (только кодирование) — для Basic-auth, без новых зависимостей. */
|
||||
private fun base64(input: String): String {
|
||||
val bytes = input.encodeToByteArray()
|
||||
val out = StringBuilder()
|
||||
var i = 0
|
||||
while (i < bytes.size) {
|
||||
val b0 = bytes[i].toInt() and 0xFF
|
||||
val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xFF else 0
|
||||
val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xFF else 0
|
||||
val triple = (b0 shl 16) or (b1 shl 8) or b2
|
||||
out.append(B64[(triple ushr 18) and 0x3F])
|
||||
out.append(B64[(triple ushr 12) and 0x3F])
|
||||
out.append(if (i + 1 < bytes.size) B64[(triple ushr 6) and 0x3F] else '=')
|
||||
out.append(if (i + 2 < bytes.size) B64[triple and 0x3F] else '=')
|
||||
i += 3
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
@@ -42,6 +42,35 @@ fun defaultHttpClient(): HttpClient = HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-клиент для media-mirror: тяжёлые опросы (mirror.list с большим лимитом)
|
||||
* на сервере считаются заметно дольше, чем обычные запросы, поэтому таймауты
|
||||
* шире [defaultHttpClient] — иначе живой, но медленный ответ упирается в таймаут.
|
||||
*/
|
||||
fun defaultMirrorHttpClient(): HttpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
explicitNulls = false
|
||||
}
|
||||
)
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
connectTimeoutMillis = 15_000
|
||||
requestTimeoutMillis = 90_000
|
||||
}
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
|
||||
}
|
||||
}
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
/** Флаг debug-логирования HTTP (по умолчанию включён). */
|
||||
object HttpClientDebugLogging {
|
||||
var enabled: Boolean = true
|
||||
|
||||
@@ -2,6 +2,8 @@ package pw.binom.viewmate.core.media
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
@@ -19,7 +21,7 @@ import io.ktor.http.contentType
|
||||
class MirrorClient(
|
||||
private val baseUrl: String, // https://mirror.binom.pw
|
||||
private val apiKey: String, // X-API-Key
|
||||
private val http: HttpClient = defaultHttpClient(),
|
||||
private val http: HttpClient = defaultMirrorHttpClient(),
|
||||
) {
|
||||
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
|
||||
suspend fun createMirror(itemId: String): MirrorJob {
|
||||
@@ -42,8 +44,11 @@ class MirrorClient(
|
||||
return response.body()
|
||||
}
|
||||
|
||||
/** Список зеркал: статус (null — все) + лимит. */
|
||||
/** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
|
||||
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
|
||||
var last: Exception? = null
|
||||
repeat(LIST_ATTEMPTS) { attempt ->
|
||||
try {
|
||||
val response = http.get("$baseUrl/api/mirror") {
|
||||
header("X-API-Key", apiKey)
|
||||
parameter("limit", limit)
|
||||
@@ -51,6 +56,14 @@ class MirrorClient(
|
||||
}
|
||||
response.ensureSuccess("mirror.list")
|
||||
return response.body()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
last = e
|
||||
if (attempt < LIST_ATTEMPTS - 1) delay(LIST_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
|
||||
}
|
||||
|
||||
/** Удаление зеркала (204). */
|
||||
@@ -69,4 +82,11 @@ class MirrorClient(
|
||||
response.ensureSuccess("mirror.scan")
|
||||
return response.body()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
|
||||
private const val LIST_ATTEMPTS = 2
|
||||
|
||||
private const val LIST_RETRY_DELAY_MS = 1_500L
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user