Compare commits

30 Commits

Author SHA1 Message Date
subochev e81026b9f5 Слои записи и оверлей берут стт-статус и сессию из единого GlassesStt 2026-09-07 04:27:05 +03:00
subochev 7db0fc71de Слои записи и оверлей берут стт-статус и сессию из единого GlassesStt 2026-09-07 03:59:15 +03:00
subochev 8083978849 Слой записи владеет микрофоном и распознаванием, движок — только диалогом 2026-09-07 03:27:31 +03:00
subochev 1b817aa9a6 Сохраняем прогресс просмотра и возобновляем с позиции 2026-09-07 01:09:44 +03:00
subochev 79a500150d Пейринг RayNeo: кнопка ручного (без QR) ре-паринга + debug-роуты + keep-screen-on
- MercuryBridge.pairClassicAuto: классический discovery → найти RayNeo по
  имени → createBond (без QR и без камеры очков) для случая «бонд слетел,
  очки в first-pairing». discoverGlasses — список найденных.
- MercuryPanel: кнопка «🔗 паринг (вручную)» рядом с QR-парингом
- DebugHttpServer: /mercury/pair, /mercury/pair/stop, /mercury/pair/classic,
  /mercury/unpair, /mercury/bonded, /mercury/discover, поля pairing в /status
- keep-screen-on, чтобы QR/состояние реально были на экране телефона
2026-09-04 03:08:38 +03:00
subochev f68ea10342 Сборка release: подпись ключом Binom (v2+v3) + таск assembleSigned
signingConfigs в app-phone/app-glasses берут кестор и креды из скилла
android-sign (вне git); без кестора release собирается без подписи.
assembleSigned — собрать оба приложения одним шагом.
2026-09-04 03:08:28 +03:00
subochev 5c86ed00b4 STT: очки распознают речь локально и шлют готовый текст (SttText) в телефон
- glasses: локальный ASR через общий pw.binom.asr (GlassesAsr/GlassesSttSession/SttModelProvider)
- protocol: SttText — очки присылают распознанный текст в телефон; телефон кидает его в LLM
  (старый SttAudio/StopStt оставлен фолбэком для старых очей)
- phone: SttStreamer делегирует общему ASR; GlassesServer роутит SttText в LLM
- lib-core: AudioPrep общий
2026-09-04 03:08:26 +03:00
subochev 0b3f9ff975 Mirror: медленный list(500) не роняет каталог и не дёргается в цикле
- отдельный defaultMirrorHttpClient (connect 15с / request 90с) + 1 повтор на list():
  тяжёлый GET /api/mirror?limit=N на сервере дольше общего 30с-таймаута и подёргивается (503);
- каталог: зеркала грузятся в отдельном try — сбой/таймаут не чистит уже загруженный каталог,
  просто временно без бейджей;
- детали: убрали тяжёлый list(limit=500) каждые 3 с (progress зеркала на сервере не живой, 0
  до конца) — в цикле опрашивается только дешёвый mirrorByItem.
2026-09-02 23:02:56 +03:00
subochev 40723fa584 Каталог: массовые действия (конвертировать/скачать/удалить), фильтр «только скачанные» и BackHandler вложенных экранов 2026-09-02 22:38:54 +03:00
subochev 728116662e Добавляем WatchProgressStore (watchprogress.json) и его тесты 2026-09-02 22:38:30 +03:00
subochev 0cfd92a68f Добавляем экран «Активность»: что сейчас конвертируется и скачивается 2026-09-02 22:37:31 +03:00
subochev f656f43e10 Сохраняем прогресс просмотра и возобновляем с позиции 2026-09-02 20:55:00 +03:00
subochev 1b28760c6e Добавляем в бейдж каталога статусы загрузки и готовности видео на очки 2026-09-02 19:53:21 +03:00
subochev b2e2d2697d Добавляем индикатор рассинхронизации звука и видео в статус-панели очков 2026-09-02 19:27:11 +03:00
subochev 92b483a5e3 Добавляем свёрнутый блок результата тула в чат с раскрытием по тапу 2026-09-02 02:49:48 +03:00
subochev edf7cf63f6 Внедряем единый тул-канал для локального LiteRT и удалённого LLM-клиента 2026-09-02 02:42:00 +03:00
subochev d5e60a2565 Заменяем litertlm-android 0.14.0 на KMP-обёртку litert-google 3 2026-09-01 18:46:18 +03:00
subochev 4587e22b50 Приложение добавляет OpenAI-совместимый HTTP-сервер и KMP-обёртку LiteRT 2026-09-01 18:45:25 +03:00
subochev e18b82642f audio-sync: учитывать латентность линка очки→телефон
Очки штампуют sentTs в PlaybackPosition; телефон мерит one-way
латентность (receivedTs - sentTs, EWMA) и добавляет её к мастер-позиции
в sync(), снимая системный перекос, из-за которого аудио недокручивало
на величину транспортной задержки. Плюс правки тестов под
HARD_SEEK_THRESHOLD_MS=5000 (остались незакомиченными с прошлого фикса).
2026-08-30 05:08:37 +03:00
subochev b11879520e fix(phone): поднимаем порог жёсткого seek аудио 2000→5000 мс
Жёсткий seekTo при |audio-video|>2000мс перебуферизировал ExoPlayer и давал
слышимую тишину (0.5-1с) + прыжок позиции. На локальном файле обычная буферная
пауза вывода пробивала порог и зацикливала seek→пауза. Порог 5000мс: умеренный
рассинхрон закрывается плавной коррекцией скоростью (бесшумно). Плюс диагностика:
лог типа источника (локальный файл/стриминг S3) и STATE_BUFFERING ExoPlayer,
и пик diff в логе seek.
2026-08-30 04:50:37 +03:00
subochev 3af74851db feat(mercury): синхронизировать часы очков с телефоном на подключении
RayNeoMercury.syncTime() шлёт текущее время телефона на очки. Вызываем
на событии Connected, чтобы очки получали корректную метку сразу после
подключения — это устраняет первопричину клина лог-спула (очки стартуют
с ~1970 до NTP, Loki отвергает batch с устаревшим таймстемпом, 400).
2026-08-30 03:42:47 +03:00
subochev 1aa389a3a0 fix(log): разблокировать спул лог-коллектора при устаревших таймстемпах
Раньше одна пачка в спуле с таймстемпом вне окна приёма Loki (≈7 суток)
топила весь пуш: Loki отвечал 400 на весь batch, спул никогда не чистился,
и новые логи (в т.ч. телефона) не доходили. Теперь flush() выгребает ВСЕ
готовые пачки за тик (FIFO) и отбрасывает записи вне окна Loki, а при
неудаче отправки спула буфер не трогается (ждёт следующий тик). Добавлены
тесты на очистку устаревших записей и FIFO-ретрай спула.
2026-08-30 03:30:39 +03:00
subochev 6cd4a8de43 feat: управление яркостью/WiFi очков через Mercury + накопленные доработки
- Mercury (app-phone): управление яркостью (с авто-выкл. перед
  применением) и WiFi (скан/подключение/список сетей с паролем);
  добавлено право BLUETOOTH_ADVERTISE в манифест и runtime-запрос
  (без него GATT-сервер не рекламируется → очки не подключаются)
- логирование (lib-core core.log): BatchingLogCollector + LogSink +
  LogSpool + LokiLogSink; цепочка очки→телефон→Loki; lib-core log()
  переадресован в коллектор
- STT: Qwen3-ASR через pw.binom.asr:asr-qwen3-android, убран in-house
  whisper-фоллбек
- audio-sync: P-регулятор с насыщением вместо фиксированной скорости
- media-split: очки только video, телефон только audio (UI-чипы)
- Components: форматирование размера через Locale.ROOT
2026-08-29 23:06:52 +03:00
subochev 62d2f06797 soft sync audio via PlaybackParameters speed (rate-based, no seekTo jump)
Master = glasses (5s PlaybackPosition), slave = phone audio.
Replace aggressive seekTo-drift-corrector with smooth tempo correction:
- |diff|<=150ms (TRIGGER_BAND) -> speed 1f (hysteresis, no flutter)
- audio lags -> speed 1.02 (catch up)
- audio ahead -> speed 0.98 (slow down)
- |diff|>2000ms -> rare hard seekTo + reset speed 1f
- removed catch-up seekTo after +1s (was adding ~2s error)
- log now prints speed
AudioSyncPlayerTest: 18 cases (boundaries 150/2000, speed up/down/1f, play/pause).
2026-08-27 23:23:50 +03:00
subochev cf8c5ef604 ui(phone): переключение сессий ступенчатым свайпом (порог 120dp, ±1 сессия за жест) + крупнее текст сообщений (14→20sp) 2026-08-27 18:46:31 +03:00
subochev 236ff18543 ui(glasses): оверлей ассистента накрывает весь вьюпорт (fillMaxSize, непрозрачный чёрный) — независимо от масштаба и регулятора «Высота» видео 2026-08-27 18:46:31 +03:00
subochev 412e093174 ui(glasses): отмена первой записи не закрывает оверлей — возврат в «слушаю», диалог жив даже при пустой истории; выход из режима — только явный жест ExitMode 2026-08-27 18:46:29 +03:00
subochev da8182443c stt(phone): SttDebug переведён на Qwen3-ASR-0.6B (отладочный прогон wav) 2026-08-25 21:30:09 +03:00
subochev 17385d1784 stt(phone): Qwen3-ASR-0.6B int8 вместо whisper-small (константа STT_BACKEND, hotwords тулсет/тулы/умеешь) — откат в одну строку (opencode по ТЗ) 2026-08-25 19:57:33 +03:00
subochev 98ac84ffe8 stt(phone): сегментация буфера на фразы перед whisper (лимит ≤30с) — длинные сессии распознаются целиком, фразы склеиваются пробелом (opencode по ТЗ) 2026-08-25 18:09:56 +03:00
99 changed files with 7059 additions and 969 deletions
+119
View File
@@ -0,0 +1,119 @@
# TASK: сегментация STT-буфера на фразы (whisper ≤30с)
## Проблема (доказана на железе 25.08, полевым тестом ac56e70)
sherpa-onnx OfflineRecognizer (whisper-small int8) принимает волны ТОЛЬКО ≤ 30 с:
лог движка «Only waves less than 30 seconds are supported. We process only the
first 30 seconds and discard the remaining data». При слитном буфере сессии
(полевой тест: 117.6 с после trimEdges) whisper обработал первые 30 с (тишина)
и выдал галлюцинации («Убираем. Убираем…», «Хуйню ты на распознавал»), а вся
речь после 30 с молча выброшена. Проверено на хосте тем же движком
(sherpa-onnx 1.13.6): тот же raw, нарезанный скользящим окном 30 с (шаг 15 с),
распознаётся чисто: «Я говорю что ты умеешь? Какие тебе доступны тулы? Ну
давай, а что из этого тулсета у тебя есть? Раз, два, три, четыре, пять».
## Решение
Перед распознаванием резать буфер на фразы по паузам тишины (≥ 0.6 с),
распознавать каждую фразу отдельно (гарантированно ≤ 30 с), склеивать
результаты через ПРОБЕЛ (решение пользователя, дословно: «пробелами надо
разделять»).
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt`
— новая чистая функция `splitPhrases` + правка `recognizeFullBufferLocked`.
2. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt`
— НОВЫЙ файл, JVM-тесты `splitPhrases`.
3. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt`
— добавить ОДИН кейс «две фразы через паузу → распознаны раздельно, склеены
пробелом» (переиспользовать приватный `RecordingRecognizer` того же класса).
ЗАПРЕЩЕНО трогать: `SttMicStream.kt`, `WhisperStt.kt`, `trimEdges` (кроме
вызова), `GlassesServer.kt`, `PhoneApp.kt`, `lib-core`, протокол, весь
app-glasses. Все scratch-файлы — внутри репо, в коммит не включать.
## 1. splitPhrases — чистая internal функция в SttStreamer.kt
```kotlin
/**
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
* НЕ менять без перепроверки на тех же дампах.
*/
internal fun splitPhrases(
samples: FloatArray,
threshold: Float = 0.005f,
windowSize: Int = 320, // 20 мс @ 16 кГц
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
): List<FloatArray>
```
Семантика (точная, из полевого разбора):
- Окно [windowSize] сэмплов — ТИШИНА, если RMS < threshold.
Порог 0.005, НЕ 0.01 как в trimEdges: 0.01 режет тихие начала слов на
обрубки, whisper на обрубке выдаёт мусор. На усиленном ×3 потоке шум
~0.005–0.009, речь ~0.03–0.14.
- Пауза = подряд ≥ minSilenceWindows тихих окон (0.6 с). Пауза РАЗДЕЛЯЕТ фразы.
- Фраза = звуковые окна между паузами + отступ padWindows тихих окон с каждой
стороны (clamp к границам массива, как в trimEdges).
- Фраза короче minPhraseWindows (1.5 с): объединить с СОСЕДНЕЙ фразой (короткий
сегмент = обрубок; whisper на нём мусорит). Единственная фраза — оставить
как есть.
- Фраза длиннее maxPhraseSamples (30 с): резать жёстко на куски ≤ 30 с по
границе окна (sherpa-onnx молча обрезает длинную волну — страховка обязательна).
- Пустой вход / всё тишина → пустой список.
- Внутренние паузы < 0.6 с НЕ трогать (внутри фразы тишина остаётся: склейка
пауз ломает whisper — «де-тишина» давала «1,2,3,4,5,8,10», проверено).
## 2. recognizeFullBufferLocked — правка
Текущий код (строки ~187–211 SttStreamer.kt): `trimEdges(base)` → ОДИН вызов
`stt.recognize(chunk)`.
Новый поток:
1. `val chunk = trimEdges(base)` — как сейчас, НЕ менять.
2. `val phrases = splitPhrases(chunk)`.
3. `phrases` пуст → `return` (как сейчас при пустом chunk).
4. В executor-задаче: для КАЖДОЙ фразы последовательно `stt.recognize(phrase)`;
результат с `isBlank()` — пропускать (мусорный сегмент, в текст не вставлять);
итог — `joinToString(" ")` по непустым результатам.
5. Все фразы пустые → `full` НЕ перезаписывать (семантика finish без текста
сохраняется, вызывающий сам решит).
6. Лог: `полный буфер: N фраз (2.3с, 4.1с, …) → '$full'` — размеры фраз в
секундах, один знак после запятой.
7. `task.get(...)`: таймаут поднять с 30 до 60 секунд (N фраз × до 30 с каждая).
## 3. Тесты
PhraseSplitTest.kt — чистые JVM-тесты (синтез сигнала в стиле FullBufferSttTest:
зубчатая волна как «речь», нули как тишина; для «тихих начал» — плавное
нарастание амплитуды):
1. Две фразы (по ~2 с), пауза 0.7 с → ровно 2 элемента; обе начинаются с
звучного окна (не с тишины).
2. Пауза 0.4 с (< 0.6) НЕ разделяет → 1 элемент.
3. Тихие начала слов не режутся: фраза с окнами 0.003–0.009 на старте остаётся
целой (порог 0.005) → 1 элемент, начало фразы включает тихие окна.
4. Всё тишина → пустой список.
5. Пустой вход → пустой список.
6. Фраза 31 с → разрезана на 2 куска, каждый ≤ 30 с.
7. Две короткие фразы (0.8 с и 0.9 с, пауза 0.7 с) → объединены в одну.
FullBufferSttTest.kt — добавить кейс (внутри класса, до закрывающей `}`):
- буфер: «фраза А» (1.5 с зуба), пауза 0.7 с нулей, «фраза Б» (1.5 с зуба);
`RecordingRecognizer("А", "Б")`; после `finish()`: `calls.size == 2`,
`full == "А Б"` (пробел — разделитель по решению пользователя).
## Проверка (делает Hermes, не агент)
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
число тестов app-phone выросло на 8 (7 новых + 1 дополненный).
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
+158
View File
@@ -0,0 +1,158 @@
# TASK: замена whisper-small на Qwen3-ASR-0.6B int8 (sherpa-onnx, CPU)
## Проблема (доказана на железе 25.08, полевым тестом ac56e70 + серверным экспериментом)
whisper-small int8 распознаёт русскую речь посредственно: «Какие тебе доступные
толулы?», «Ну давай, а что из этого туалета у тебя есть?» (было на дампах
пользователя). Qwen3-ASR-0.6B (тот же движок sherpa-onnx 1.13.6, тот же CPU)
без подсказок — ровня висперу, а с hotwords («тулсет,тулы,умеешь») исправляет
все провалы в ноль: «Какие тебе доступные тулы?», «Ну давай, а что из этого
тулсета у тебя есть?», «Что ты умеешь?». Эксперимент проведён на дампах
пользователя тем же движком и той же моделью (int8), скорость на серверном CPU
0.1-0.2 от реального времени (фраза 4.4 с → 0.9 с). Решение пользователя:
остаёмся на CPU (аппаратное ускорение NNAPI для трансформеров не даёт выигрыша).
## Решение
Новый класс `Qwen3AsrStt` (реализует тот же интерфейс `PhraseRecognizer`, что и
`WhisperStt`) + переключатель-константа в фабрике `PhoneApp.ensureStt()`.
Старый `WhisperStt` НЕ удалять — откат в одну строку. Сегментация по паузам
(`splitPhrases` + склейка пробелом в `SttStreamer`) НЕ меняется — Qwen3-ASR тоже
имеет лимит max_total_len=512 токенов (≈ 30-40 с аудио), нарезка остаётся в силе.
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt` — НОВЫЙ файл.
2. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt` — только
`ensureStt()` (строки ~350-366) + 2 константы в companion object (строка 512).
ЗАПРЕЩЕНО трогать: `WhisperStt.kt`, `SttStreamer.kt`, `SttDebug.kt`,
`GlassesServer.kt`, `lib-core`, протокол, весь app-glasses. Все scratch-файлы —
вне репо или в коммит не включать.
## 1. Qwen3AsrStt.kt — новый класс
```kotlin
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
class Qwen3AsrStt(
private val convFrontendPath: String,
private val encoderPath: String,
private val decoderPath: String,
private val tokenizerDir: String,
private val numThreads: Int = 4,
private val hotwords: String = "",
) : PhraseRecognizer {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
qwen3Asr = OfflineQwen3AsrModelConfig(
convFrontend = convFrontendPath,
encoder = encoderPath,
decoder = decoderPath,
tokenizer = tokenizerDir,
maxTotalLen = 512,
maxNewTokens = 128,
temperature = 1e-6f,
topP = 0.8f,
seed = 42,
hotwords = hotwords,
),
numThreads = numThreads,
provider = "cpu",
modelType = "qwen3_asr",
),
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}
```
Точные имена полей и сигнатуры подтверждены байткодом sherpa-onnx v1.13.6
(`OfflineQwen3AsrModelConfig` — поля convFrontend/encoder/decoder/tokenizer/
maxTotalLen/maxNewTokens/temperature/topP/seed/hotwords; `OfflineModelConfig` —
поле qwen3Asr; нативная строка `qwen3_asr=` подтверждает modelType).
Дефолты декодирования — из C++-конфига sherpa-onnx 1.13.6: max_total_len=512,
max_new_tokens=128, temperature=1e-6, top_p=0.8, seed=42. Поле `tokens` в
OfflineModelConfig для qwen3_asr НЕ передавать (токенизатор — директория в
qwen3Asr.tokenizer, не файл).
## 2. PhoneApp.kt — фабрика и константы
В companion object (строка 512) добавить:
```kotlin
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
private const val STT_BACKEND = "qwen3_asr"
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
```
В `ensureStt()` (строки ~350-366) — заменить блок создания whisper на:
```kotlin
val qwenConv = File(modelsDir, "conv_frontend.onnx")
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
val qwenDec = File(modelsDir, "decoder.int8.onnx")
val qwenTok = File(modelsDir, "tokenizer")
if (STT_BACKEND == "qwen3_asr" &&
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
) {
stt = Qwen3AsrStt(
convFrontendPath = qwenConv.absolutePath,
encoderPath = qwenEnc.absolutePath,
decoderPath = qwenDec.absolutePath,
tokenizerDir = qwenTok.absolutePath,
numThreads = 4,
hotwords = STT_HOTWORDS,
)
} else {
val encoder = File(modelsDir, "small-encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt")
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null
}
stt = WhisperStt(
encoderPath = encoder.absolutePath,
decoderPath = decoder.absolutePath,
tokensPath = tokens.absolutePath,
numThreads = 4,
)
}
```
Тип `stt` — `PhraseRecognizer` (локальная переменная в synchronized-блоке;
дальше `SttStreamer(stt = stt, ...)` как сейчас). Лог создания: добавить строку
`log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")` перед созданием
SttStreamer. Старая ветка whisper остаётся для отката.
## Проверка (делает Hermes, не агент)
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
число тестов app-phone НЕ меняется (новый класс не покрывается JVM-тестами —
Android + нативная библиотека; splitPhrases/trimEdges не трогаются).
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
- Дальше Hermes сам: adb push моделей (conv_frontend.onnx, encoder.int8.onnx,
decoder.int8.onnx, tokenizer/ — из /root/viewmate/models/sherpa-onnx-qwen3-asr-0.6B-int8/)
на телефон в filesDir/models + установка APK + полевая приёмка на железе.
+84
View File
@@ -0,0 +1,84 @@
# TASK: мягкая синхронизация аудио через скорость (rate-based sync)
## Контекст / проблема
В `view-mate` звук играет на **телефоне** (ExoPlayer media3), видео — на **очках** (очки — мастер, шлют позицию каждые 5с через `PlaybackPosition`). Телефон подстраивает аудио под позицию очков.
Сейчас коррекция в `AudioSyncPlayer.sync()` грубая:
- раз в 5с (на каждый PlaybackPosition от очков) вызывается `sync(positionMs, isPlaying)`;
- если `abs(audioPos - videoPos) > 1000` → **`seekTo(videoPos)`** — резкий рывок;
- после seek ещё «догонка» через 1с (второй `seekTo`).
Проблема: звук на телефоне стабильно уезжает на ~1с вперёд от видео очков (статика `diff=1000` в паузе; при живом просмотре набирает >1000). Грубый `seekTo` при каждом пересечении порога рождает **повтор фразы** («нас заметили … нас заметили») и кодечки — слышимый дефект.
## Задача
Заменить резкий `seekTo`-корректор на **мягкую коррекцию через темп воспроизведения (PlaybackParameters.speed)**. Очки остаются мастером, телефон — ведомым: аудио подстраивается под позицию очков плавно, без рывков и повторов.
## Конкретные требования
### 1. Чистая функция решателя (заменить `decideSync`)
Переписать `decideSync` (сейчас возвращает `SyncDecision(seekToMs, playing)`) так, чтобы он возвращал **скорость** и лишь экстремально — seek:
```kotlin
internal data class SyncDecision(
val speed: Float = 1f, // желаемая скорость аудио
val seekToMs: Long? = null, // только при большом рассинхроне (> HARD_SEEK_THRESHOLD)
val playing: Boolean? = null,
)
```
Правила (пороги — константами, из названий ясны):
- `abs(diff) <= SOFT_DRIFT_MS` (например 150мс) → связь «в норме»: `speed = 1f`.
- `audioPos < videoPos` (звук отстаёт) → подгонять **вперёд**: `speed = 1f + DRIFT_RATE` (например `1.02`).
- `audioPos > videoPos` (звук впереди) → подгонять **назад**: `speed = 1f - DRIFT_RATE` (например `0.98`).
- `abs(diff) > HARD_SEEK_THRESHOLD` (например 2000ms) → **`seekToMs = videoPos`** (рассинхрон большой, скорость не нагонит вовремя) — но это редкость.
- `playing` — как раньше (догон play/pause по состоянию мастера).
Учесть гистерезис: не менять направление скорости слишком часто. Например, помнить последнее направление и не переключать на «назад/вперёд» чаще чем раз в N (или не переключать, пока diff не станет > небольшой зоны около 0). Мин. простой вариант: **TRIGGER_BAND_MS** — скорость меняется/выравнивается только когда diff за пределами зоны `[-TRIGGER_BAND, +TRIGGER_BAND]` вокруг 0 (например 150ms); внутри зоны — `1f`. Это убирает дребезг.
### 2. Применение в `AudioSyncPlayer.sync()`
- Вызов `decideSync` с новым набором параметров.
- Если `decision.seekToMs != null` — текущий `seekTo` (редкий случай, большой рассинхрон) + восстановить `speed=1f` после seek (seek сбрасывает позицию, дальше мягко).
- Иначе (обычный путь): `player.setPlaybackParameters(PlaybackParameters(speed))` — **без `seekTo`**.
- Убрать «догонку через 1с» (второй резкий seek) — при мягкой коррекции она не нужна и вредна.
- `lastVideoPositionMs`, `lastVideoPlaying` — сохранить семантику из текущего.
Требование: **все операции ExoPlayer по-прежнему на main-потоке** (уже есть `main.post`). `setPlaybackParameters` вызывать внутри `main.post`.
### 3. Логирование
- В `sync` логировать теперь скорость: `audio sync: video=N audio=M diff=K speed=S` (S — применяемая скорость или 1f). Полезно для QA.
- При seek (редкий) — как сейчас `audio sync: seek → N`.
### 4. Тесты (обязательно)
Переписать/добавить unit-тесты на новую `decideSync` (и/или rename в `decideSpeed`/`decideAdjustment`):
- `audioPos` близко к `videoPos` (diff < порога мягкой зоны) → `speed == 1f`, `seekToMs == null`;
- `audioPos` отстаёт (audio < video, диф в зоне до HARD) → `speed > 1f`, без seek;
- `audioPos` впереди (audio > video, в зоне до HARD) → `speed < 1f`, без seek;
- `abs(diff) > HARD_SEEK_THRESHOLD` → `seekToMs == videoPos`, speed по-прежнему 1f (после seek мягко подогнать);
- `playing` (play/pause) отдельно — без влияния на speed;
- boundary: ровно на границах порога (equals) — чёткое поведение.
Проверить, что старые тесты `AudioSyncPlayerTest` переписаны под новый контракт (скорость вместо seek в обычных случаях).
### 5. Не трогать фильмы/прошлое поведение
- На очках ничего менять (мастер позиции остаётся).
- `play(url, startPositionMs)` — оставить: начальная позиция аудио при старте — `startPositionMs`, потом мягкая подгонка.
- Суть: только `_phone_`/`AudioSyncPlayer` меняется.
## Файлы
- `app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt` (основной).
- `app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt` (тесты).
## Требования к исполнению / команды
- Сборка и прогон тестов только через **локальный gradle** в репо (не трогать чужой infra).
- Прогнать: `./gradlew :app-phone:testDebugUnitTest` (или `:app-phone:test` в исходном тесте — уточнить task по проекту) — тесты модуля phone проходят.
- Код только на **Kotlin**, в стиле проекта (см. соседние файлы). Без лишних зависимостей.
- НЕ коммитить сами — собрать и прогнать тесты, показать диф. (Коммит — после подтверждения здоров: образец ты решаешь/проверяешь сам.)
+42
View File
@@ -1,3 +1,5 @@
import java.io.File
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -5,6 +7,20 @@ plugins {
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
} }
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
val binomCertsDir: File = File(
(project.findProperty("binom.keystore.dir") as? String)
?: System.getenv("BINOM_KEYSTORE_DIR")
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
)
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
val binomCreds: Map<String, String> =
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
else emptyMap() })
android { android {
namespace = "pw.binom.viewmate.glasses" namespace = "pw.binom.viewmate.glasses"
compileSdk = 35 compileSdk = 35
@@ -17,9 +33,30 @@ android {
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0" versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
} }
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
// Без кестора binomCreds пусто → release остаётся неподписанным.
if (binomCreds.isNotEmpty()) {
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
signingConfigs {
create("binom") {
storeFile = binomStoreFile
storePassword = binomStorePass
keyAlias = binomAlias
keyPassword = binomKeyPass
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
} }
} }
@@ -61,6 +98,11 @@ dependencies {
implementation(project(":lib-core")) implementation(project(":lib-core"))
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Локальный 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 VAD — лёгкий, без моделей; схема pair-view автора)
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 — // VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
// whisper распознаёт полный поток без VAD (проверено на сыром дампе). // whisper распознаёт полный поток без VAD (проверено на сыром дампе).
@@ -42,17 +42,19 @@ class BtGlassesTransport(
companion object { companion object {
/** /**
* Runtime-пермишены Bluetooth, зависящие от версии платформы * Runtime-пермишены, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12): * (TASK-transport.md п.3, п.5: Android 12+ / до 12) + RECORD_AUDIO:
* микрофон локального STT — без него AudioRecord не стартует и сессия
* молча падает в файловую эмуляцию (пустой буфер).
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery), * 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме). * ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/ */
fun runtimePermissions(): Array<String> = 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) arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
} else { } else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN) arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
} }) + Manifest.permission.RECORD_AUDIO
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun hasBtPermissions(ctx: Context): Boolean = private fun hasBtPermissions(ctx: Context): Boolean =
@@ -0,0 +1,182 @@
package pw.binom.viewmate.glasses
import android.view.MotionEvent
import androidx.compose.runtime.Composable
import com.rayneo.arsdk.android.touch.FlingArgs
import com.rayneo.arsdk.android.touch.TempleAction
/**
* Дискретный жест трекпада. Плавного (continuous) жеста нет: [TempleAction.SlideContinuous]
* по знаку дельты сворачивается в дискретный свайп ([toGesture]) — плавной прокрутки диалога
* ассистента больше не нужно.
*/
enum class Gesture { Click, DoubleClick, TripleClick, SwipeForward, SwipeBackward }
/**
* SDK-событие трекпада → дискретный [Gesture]. Возвращает null, если жест игнорируется
* (MoveUp/ActionUp/Idle/LongClick). [TempleAction.SlideContinuous] → дискретный свайп по знаку
* дельты (>=0 вперёд).
*/
fun TempleAction.toGesture(): Gesture? = when (this) {
is TempleAction.Click -> Gesture.Click
is TempleAction.DoubleClick -> Gesture.DoubleClick
is TempleAction.TripleClick -> Gesture.TripleClick
is TempleAction.SlideForward -> Gesture.SwipeForward
is TempleAction.SlideBackward -> Gesture.SwipeBackward
is TempleAction.SlideContinuous -> if (this.delta >= 0f) Gesture.SwipeForward else Gesture.SwipeBackward
else -> null
}
/**
* Обратный [toGesture] — [Gesture] → SDK-событие, чтобы маршрутизацию layout-а прогнать через
* единственную матрицу [pw.binom.viewmate.glasses.gestures.gestureDecision] (она ждёт
* [TempleAction]). Плавного жеста нет — дискретные свайпы.
*/
fun Gesture.toAction(): TempleAction = when (this) {
Gesture.Click -> TempleAction.Click()
Gesture.DoubleClick -> TempleAction.DoubleClick()
Gesture.TripleClick -> TempleAction.TripleClick()
Gesture.SwipeForward -> TempleAction.SlideForward(DUMMY_FLING)
Gesture.SwipeBackward -> TempleAction.SlideBackward(DUMMY_FLING)
}
/**
* Плейсхолдер [FlingArgs] для свайпов в [toAction]: матрица [gestureDecision] смотрит только
* тип жеста, args свайпа ей не нужны, поэтому одна переиспользуемая заглушка (один
* [MotionEvent] на всё время — pool-утечки нет).
*/
private val DUMMY_FLING: FlingArgs = run {
val e = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0)
FlingArgs(e, e, 0f, 0f)
}
/**
* Экран/слой очков. Рисуется композитом в стек [LayoutManager]: снизу вверх, между слоями
* затемнение. [gesture] вызывается ТОЛЬКО вершиной стека — [LayoutManager.dispatch] маршрутизирует
* каждый жест в [Layout.gesture] активного (верхнего) layout-а, остальные слои жест не получают.
*/
interface Layout {
/** Основной экран слоя (видео/чат и т.п.). */
@Composable
fun drawStage()
/** Панель состояния: вызывается первой, потом стандартные время/заряд. */
@Composable
fun drawStatusPanel()
/** Обработка жестов данным слоем. Вызывается менеджером только для верхнего слоя. */
fun gesture(g: Gesture)
/**
* Слой открыт (встал вершиной стека) — менеджер вызывает сразу после [LayoutContext.open].
* Под-слой использует для своего «старта»: напр. [pw.binom.viewmate.glasses.ui.layers.ListeningLayout]
* здесь открывает микрофон. Корневой слой не получает (он не «открывается», а создаётся менеджером).
*/
fun onEnter() {}
/** Слой закрыт (POP-нут из стека) — менеджер вызывает в [LayoutContext.close]. Симметрично [onEnter]. */
fun onExit() {}
}
/**
* Контекст, доступный layout-у: открыть под-слой ([open]), закрыть себя ([close]), управлять
* глобальным индикатором записи ([setRecordingIndicator]). Индикатор горит красным, пока активен
* хотя бы один слой, включивший его; выключается, когда этот слой перестал быть активным (POP)
* или переключил его в false. Экземпляр создаёт [LayoutManager] — каждый layout получает один
* общий контекст (самого менеджера).
*/
interface LayoutContext {
fun setRecordingIndicator(on: Boolean)
/** Закрыть текущий (верхний) слой. Корневой слой закрыться не может. */
fun close()
/** Выйти до корня (корневой слой остаётся). */
fun closeToRoot()
/**
* Открыть новый слой поверх: менеджер сам создаст layout через [factory] и передаст ему
* контекст. Инвариант — только активный (верхний) слой открывает под-слой.
*/
fun open(factory: (LayoutContext) -> Layout)
}
/**
* ЛIFO-стек layout-ов очков (то, что пользователь называет «стек»): корень (двойным кликом не
* вытолкнуть) → сверху под-слои. Владеет стеком и контекстом одновременно — один объект, без
* «расщеплённого» context/manager. Корень создаётся [rootFactory], которому менеджер отдаёт сам
* себя как [LayoutContext] (корень открывает под-слои через [LayoutContext.open]).
*
* Композитинг — [compositing]: рисуются ВСЕ слои снизу вверх, между ними затемнение
* (например `фильм + затемнение + ассистент`). Жесты — [dispatch] в вершину.
*/
class LayoutManager(
rootFactory: (LayoutContext) -> Layout,
) : LayoutContext {
private val stack = ArrayDeque<Layout>()
private val recordingOn = linkedSetOf<Layout>()
/** Колбэк на каждую mутацию стека (open/close/closeToRoot) — для внешних проекций (напр. navStack). */
var onChange: (List<Layout>) -> Unit = {}
init {
stack.addLast(rootFactory(this))
onChange(stack.toList())
}
/** Верхний (активный) слой. */
val current: Layout get() = stack.last()
/** Корневой слой (не выталкивается; владелец медиастейта, напр. [MoveLayout]). */
val root: Layout get() = stack.first()
/** Глубина стека (1 = только корень). */
val depth: Int get() = stack.size
/** Стек снизу вверх. */
fun layersBottomUp(): List<Layout> = stack.toList()
/** Композит: каждый слой + флаг затемнения (затемнять каждый слой, кроме верхнего). */
data class Layer(val layout: Layout, val dim: Boolean)
/** Композит снизу вверх: film → dim → assistant. Верхний (последний) — без затемнения. */
fun compositing(): List<Layer> =
stack.mapIndexed { i, l -> Layer(l, i < stack.lastIndex) }
/** Маршрутизация жеста в верхний слой — единственная точка входа. */
fun dispatch(g: Gesture) {
current.gesture(g)
}
// LayoutContext:
override fun open(factory: (LayoutContext) -> Layout) {
val child = factory(this)
stack.addLast(child)
child.onEnter()
onChange(stack.toList())
}
override fun close() {
// Корневой слой не закрывается.
if (stack.size > 1) {
val popped = stack.removeLast()
popped.onExit()
recordingOn.remove(popped)
onChange(stack.toList())
}
}
/** Выйти до корня (корень остаётся). */
override fun closeToRoot() {
while (stack.size > 1) close()
}
override fun setRecordingIndicator(on: Boolean) {
val top = current
if (on) recordingOn.add(top) else recordingOn.remove(top)
}
/** Индикатор записи: горит, пока активен хотя бы один слой с включённым индикатором. */
fun recordingIndicator(): Boolean = recordingOn.isNotEmpty()
}
@@ -4,7 +4,18 @@ import android.app.Application
import android.content.Context import android.content.Context
import com.rayneo.arsdk.android.MercurySDK import com.rayneo.arsdk.android.MercurySDK
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.CoroutineScope
import pw.binom.viewmate.core.log.BatchingLogCollector
import pw.binom.viewmate.core.log.FileLogSpool
import pw.binom.viewmate.core.log.SystemClock
import pw.binom.viewmate.glasses.gestures.NavFrame
import pw.binom.viewmate.glasses.stt.GlassesStt
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
import pw.binom.viewmate.glasses.ui.layers.toFrame
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */ /** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
data class ChatEntry(val role: String, val text: String) data class ChatEntry(val role: String, val text: String)
@@ -38,13 +49,42 @@ class GlassesApp : Application() {
lateinit var hostConnection: HostConnection lateinit var hostConnection: HostConnection
private set private set
lateinit var movieController: MovieController
private set
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */ /** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
lateinit var downloadManager: GlassesDownloadManager lateinit var downloadManager: GlassesDownloadManager
private set private set
/** Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте. */
lateinit var stt: GlassesStt
private set
/**
* Персистентный менеджер стека layout-ов (корень [MoveLayout] получает [LayoutContext] и
* контекст приложения для своего медиастейта). Единственный владелец стека: слои
* открывают/закрывают кадры через [LayoutContext.open]/[LayoutContext.close];
* [navStack] — проекция этого стека для оверлея.
*/
val layoutManager = LayoutManager { ctx -> MoveLayout(ctx, this) }
/** Корневой слой (владелец медиастейта): протокол/lifecycle/эффекты дотягиваются до
* [MovieController] через него ([rootLayer.controller]). */
val rootLayer: MoveLayout get() = layoutManager.root as MoveLayout
/**
* Движок ассистента: внятный стейт диалога (фазы/тексты/история) + его жестовые эффекты.
* Владелец стейта, живёт всё время приложения; слои [AssistantLayout]/[ListeningLayout]
* и оверлей дотягиваются до него, протокол/lifecycle пишут через [assistantEngine].
* Зависимости от [GlassesApp] (stt/hostConnection/rootLayer) — лениво, в методах.
*/
val assistantEngine = AssistantEngine(this)
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
lateinit var logCollector: BatchingLogCollector
private set
/** Скоуп фоновых корутин (таймер лог-коллектора). */
private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE) getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
} }
@@ -55,35 +95,27 @@ class GlassesApp : Application() {
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */ /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f) val vertical = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */ // Стейт диалога ассистента (thinking/assistantText/chatHistory/answerScrollDelta + idle-hint
val listening = MutableStateFlow(false) // sttNoSpeech) — в [assistantEngine], владелец стейта ассистента. Микрофон и распознавание
// (sttText, старт/стоп стт-сессии) владеет слой [ListeningLayout]; «модель не скачалась» —
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ // из stt.status. Движок микрофоном не владеет.
val sttText = MutableStateFlow("")
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/** /**
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом * Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона, * вытолкнуть) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон, чат на фоне).
* новый ответ (ShowText) — последнее сообщение истории, не стирает её. * Тройной клик в кино кладёт «ассистент» + «слушаю»; двойной клик POP-ит верхний кадр:
* из «слушаю» → в кадр ассистента (idle, микрофон закрыт, чат не прерван), из idle/
* «думаю»/«ответ» → в «фильм». Держится до явного выхода (двойной клик в кадре
* ассистента, 30с тишины, потеря фокуса) и сбрасывается в [AssistantEngine.exitMode].
*/ */
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList()) val navStack = MutableStateFlow(listOf(NavFrame.Film))
/** /**
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText). * Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим: * (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...». * (видео на очках не играет / данных ещё нет).
*/ */
val thinking = MutableStateFlow(false) val audioSyncDiffMs = MutableStateFlow<Int?>(null)
/**
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
*/
val answerScrollDelta = MutableStateFlow(0f)
/** Текущая Activity (для закрытия приложения по команде с телефона). */ /** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null var mainActivity: MainActivity? = null
@@ -108,17 +140,44 @@ class GlassesApp : Application() {
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f) scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f) vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
MercurySDK.init(this) MercurySDK.init(this)
movieController = MovieController(applicationContext) // Медиадвижок живёт в корневом слое (rootLayer.controller), не в приложении.
movieController.eventLogger = { msg -> logToFile("mc: $msg") } rootLayer.controller.eventLogger = { msg -> logToFile("mc: $msg") }
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) } downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
hostConnection = HostConnection( hostConnection = HostConnection(
fallbackUrl = GlassesConfig.HOST_URL, fallbackUrl = GlassesConfig.HOST_URL,
movieController = movieController, movieController = rootLayer.controller,
downloadManager = downloadManager, downloadManager = downloadManager,
context = applicationContext, context = applicationContext,
) )
hostConnection.start() hostConnection.start()
log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}") log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}")
// Лог-коллектор: пачки → телефон; обрыв связи — спул в filesDir, ретраи таймером.
logCollector = BatchingLogCollector(
device = "glasses",
sink = GlassesLinkLogSink(hostConnection),
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 32),
clock = SystemClock(),
flushIntervalMs = 60_000,
maxBatchEntries = 500,
)
logCollector.start(bgScope)
attachLogCollector(logCollector)
pw.binom.viewmate.core.logCollector = logCollector
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
// Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте.
// Модель если отсутствует докачивается фоновой корутиной стта (idempotent, по Range). Куда
// уходит готовый текст (onResult) регистрирует владелец — слой записи (ListeningLayout.onEnter);
// приложение результат к mainActivity не шьёт.
stt = GlassesStt(filesDir, bgScope)
stt.ensure()
// Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager,
// navStack — лишь его зеркало. Начальная проекция (корень = Film).
layoutManager.onChange = { layouts -> navStack.value = layouts.map { it.toFrame() } }
navStack.value = layoutManager.layersBottomUp().map { it.toFrame() }
log("app", "layout-менеджер: корень=${layoutManager.current.toFrame()}, navStack=[${navStack.value}]")
} }
companion object { companion object {
@@ -99,7 +99,8 @@ internal class GlassesStateStore(private val file: File) {
/** /**
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager). * Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part * Каталог: filesDir/viewmate/{itemId}/video.mkv — на очки уходит только видео,
* звук (audio-N.ogg) не скачивается, он лежит на телефоне. Скачивание пишется в .part
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send]. * и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
*/ */
class GlassesDownloadManager( class GlassesDownloadManager(
@@ -0,0 +1,20 @@
package pw.binom.viewmate.glasses
import pw.binom.viewmate.core.log.LogBatch
import pw.binom.viewmate.core.log.LogSink
import pw.binom.viewmate.core.protocol.LogBatchMsg
/**
* LogSink коллектора очков: пачка уходит на телефон по связи очков↔телефон
* ([LogBatchMsg]). Связи нет → false: пачка прихраняется в споуле очков
* (filesDir) и ретраится после восстановления связи.
*/
class GlassesLinkLogSink(
private val connection: HostConnection,
) : LogSink {
override suspend fun send(batch: LogBatch): Boolean {
if (!connection.connected.value) return false
connection.sendToHost(LogBatchMsg(entries = batch.entries))
return true
}
}
@@ -22,6 +22,7 @@ import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.net.GlassesTransport import pw.binom.viewmate.core.net.GlassesTransport
import pw.binom.viewmate.core.net.WifiGlassesTransport import pw.binom.viewmate.core.net.WifiGlassesTransport
import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.AudioSyncInfo
import pw.binom.viewmate.core.protocol.ChatHistoryItem import pw.binom.viewmate.core.protocol.ChatHistoryItem
import pw.binom.viewmate.core.protocol.ChatHistoryMsg import pw.binom.viewmate.core.protocol.ChatHistoryMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg
@@ -39,9 +40,6 @@ import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.SetVertical import pw.binom.viewmate.core.protocol.SetVertical
import pw.binom.viewmate.core.protocol.Shutdown import pw.binom.viewmate.core.protocol.Shutdown
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson import pw.binom.viewmate.core.protocol.protocolJson
@@ -101,6 +99,8 @@ class HostConnection(
playing = snapshot.playing, playing = snapshot.playing,
itemId = snapshot.itemId, itemId = snapshot.itemId,
audioIndex = snapshot.audioIndex, audioIndex = snapshot.audioIndex,
sentTs = System.currentTimeMillis(),
durationMs = snapshot.durationMs,
), ),
) )
} }
@@ -246,9 +246,9 @@ class HostConnection(
fun sendPlaybackPositionNow() { fun sendPlaybackPositionNow() {
if (transport == null) return if (transport == null) return
scope.launch { scope.launch {
val (positionMs, playing, itemId, audioIndex) = val (positionMs, playing, itemId, audioIndex, durationMs) =
withContext(Dispatchers.Main) { movieController.playbackPosition() } withContext(Dispatchers.Main) { movieController.playbackPosition() }
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex)) sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis(), durationMs = durationMs))
} }
} }
@@ -337,60 +337,32 @@ class HostConnection(
downloadManager.handleDeleteFiles(msg.itemId) downloadManager.handleDeleteFiles(msg.itemId)
} }
is AudioSyncInfo -> {
// Индикатор |звук − видео| для статус-панели; active=false (видео не играет) — скрываем.
val prev = GlassesApp.instance.audioSyncDiffMs.value
GlassesApp.instance.audioSyncDiffMs.value = if (msg.active) msg.diffMs else null
// Диагностика: скачок diff (по часам очков) — для корреляции с logcat BT-стека телефона за ту же секунду.
if (msg.active && prev != null && msg.diffMs - prev >= 300) {
log("audio", "diff скачок $prev→${msg.diffMs} мс")
}
}
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)") is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
is ShowText -> { is ShowText -> {
addMessage("ShowText: ${msg.text}") addMessage("ShowText: ${msg.text}")
GlassesApp.instance.thinking.value = false GlassesApp.instance.assistantEngine.onShowText(msg.text)
GlassesApp.instance.answerScrollDelta.value = 0f
GlassesApp.instance.assistantText.value = msg.text
} }
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is ChatHistoryMsg -> { is ChatHistoryMsg -> {
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.") log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
GlassesApp.instance.chatHistory.value = msg.messages GlassesApp.instance.assistantEngine.onChatHistory(
.filter { it.role == "user" || it.role == "assistant" } msg.messages
.map { ChatEntry(it.role, it.content) } .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}")
}
is SttDone -> {
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
GlassesApp.instance.listening.value = false
}
is SttCancel -> {
when (msg.reason) {
SttCancel.REASON_CANCEL -> {
// Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить
// listening/thinking, НО остаться в режиме (показать последний ответ),
// микрофон заново НЕ открывать, из режима НЕ выходить.
log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме")
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = false
}
SttCancel.REASON_TIMEOUT -> {
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
log("stt", "тишина 30с — выход из режима общения")
GlassesApp.instance.mainActivity?.onSttTimeout()
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
}
else -> {
// reason="user" — пустой клик без голоса: не выходим из режима,
// возвращаемся в «слушаю» (микрофон снова открыт).
log("stt", "отменено — возврат в «слушаю»")
GlassesApp.instance.thinking.value = false
GlassesApp.instance.mainActivity?.resumeSttListening()
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
}
}
} }
is Shutdown -> { is Shutdown -> {
@@ -400,6 +372,9 @@ class HostConnection(
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo) // страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
android.os.Process.killProcess(android.os.Process.myPid()) android.os.Process.killProcess(android.os.Process.myPid())
} }
// Неподдерживаемое host-сообщение — молча игнор (в т.ч. legacy phone-STT: очки не ждут).
else -> Unit
} }
} }
@@ -1,6 +1,21 @@
package pw.binom.viewmate.glasses package pw.binom.viewmate.glasses
/** Единый лог очков: простой println (работает и в unit-тестах). */ import pw.binom.viewmate.core.log.BatchingLogCollector
/**
* Единый лог очков: println (видно в юнит-тестах) + лог-коллектор — пачки
* уходят на телефон ([GlassesLinkLogSink]); при обрыве связи пачки
* прихраняются в споуле очков (filesDir) и ретраятся позже.
*/
@Volatile
private var logCollector: BatchingLogCollector? = null
/** Подключить коллектор (GlassesApp.onCreate, после HostConnection). */
fun attachLogCollector(collector: BatchingLogCollector) {
logCollector = collector
}
fun log(tag: String, message: String) { fun log(tag: String, message: String) {
println("[view-mate] $tag $message") println("[view-mate] $tag $message")
logCollector?.record(tag, message)
} }
@@ -48,20 +48,14 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.GestureDecision
import pw.binom.viewmate.glasses.gestures.actionName import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.assistantMode
import pw.binom.viewmate.glasses.gestures.gestureDecision
import pw.binom.viewmate.glasses.status.StatusPanelCompose import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.AssistantOverlay import pw.binom.viewmate.glasses.ui.AssistantOverlay
import pw.binom.viewmate.glasses.ui.Binocular import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.LayoutStackContent
import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX import pw.binom.viewmate.glasses.ui.scaleOffsetX
@@ -70,11 +64,6 @@ import pw.binom.viewmate.glasses.ui.panelOffsetX
class MainActivity : BaseEventActivity() { class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false private var resumeAfterScreenOn = false
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
}
/** /**
* DEBUG-имитация жестов (эмулятор без трекпада): * DEBUG-имитация жестов (эмулятор без трекпада):
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click` * `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
@@ -96,10 +85,10 @@ class MainActivity : BaseEventActivity() {
when (intent?.action) { when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> { Intent.ACTION_SCREEN_OFF -> {
log("ui", "SCREEN_OFF receiver") log("ui", "SCREEN_OFF receiver")
val was = GlassesApp.instance.movieController.pauseIfPlaying() val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("SCREEN_OFF: pause wasPlaying=$was") logEvent("SCREEN_OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock() GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас (receiver) — пауза") if (was) log("ui", "экран погас (receiver) — пауза")
} }
@@ -108,7 +97,7 @@ class MainActivity : BaseEventActivity() {
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn") logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
log("ui", "экран включён (receiver) — продолжаю") log("ui", "экран включён (receiver) — продолжаю")
} }
@@ -126,24 +115,10 @@ class MainActivity : BaseEventActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
MaterialTheme(colorScheme = darkColorScheme()) { MaterialTheme(colorScheme = darkColorScheme()) {
Box( // Layout-стек (LIFO): персистентный менеджер (GlassesApp.layoutManager) композитит
modifier = Modifier // все слои снизу вверх (кино → затемнение → чат → слушаю). Триггер рекомпозиции —
.fillMaxSize() // стек менеджера (зеркало navStack, обновляется через ctx.open/close).
.background(Color.Black) LayoutStackContent(manager = GlassesApp.instance.layoutManager)
) {
val controller = GlassesApp.instance.movieController
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) {
GlassesMovieScreen(player = player, title = title)
} else {
ConnectionScreen()
}
// Оверлей режима общения: поверх любого экрана (затемняет кино),
// виден в «слушаю»/«думаю»/«ответ виден».
AssistantOverlay()
}
} }
} }
hideSystemBars() hideSystemBars()
@@ -179,16 +154,16 @@ class MainActivity : BaseEventActivity() {
val state = dm.getDisplay(displayId).state val state = dm.getDisplay(displayId).state
logEvent("display state=$state") logEvent("display state=$state")
if (state == Display.STATE_OFF) { if (state == Display.STATE_OFF) {
val was = GlassesApp.instance.movieController.pauseIfPlaying() val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("display OFF: pause wasPlaying=$was") logEvent("display OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock() GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
} else if (state == Display.STATE_ON) { } else if (state == Display.STATE_ON) {
logEvent("display ON: resume flag=$resumeAfterScreenOn") logEvent("display ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
} }
} }
@@ -219,140 +194,23 @@ class MainActivity : BaseEventActivity() {
/** /**
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast * Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку * (debugReceiver, эмулятор). Жест деспачится в верхний слой стека через персистентный
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже. * [pw.binom.viewmate.glasses.LayoutManager] — каждый слой сам решает, что со своим жестом
* делать (MoveLayout / AssistantLayout / ListeningLayout).
*/ */
private fun onTempleAction(action: TempleAction) { private fun onTempleAction(action: TempleAction) {
val app = GlassesApp.instance val g = action.toGesture() ?: run {
val state = AssistantState( log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор")
listening = app.listening.value, return
thinking = app.thinking.value, }
sttText = app.sttText.value, log("gesture", "жест: ${g}")
assistantText = app.assistantText.value, GlassesApp.instance.layoutManager.dispatch(g)
) }
when (val decision = gestureDecision(action, state)) {
GestureDecision.Ignore ->
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
GestureDecision.CallAssistant -> startAssistantMode()
GestureDecision.TogglePlayback -> togglePlayback()
GestureDecision.SendPhrase -> sendPhrase()
GestureDecision.NewPhrase -> startNewPhrase()
GestureDecision.CancelDictation -> cancelDictation()
GestureDecision.ExitMode -> exitAssistantMode()
is GestureDecision.Seek -> seekBy(decision)
is GestureDecision.ScrollAnswer -> {
val acc = app.answerScrollDelta.value + decision.delta
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
app.answerScrollDelta.value = acc
}
}
}
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */ // Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]:
private fun startAssistantMode() { // слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout.
val app = GlassesApp.instance // Activity о распознавании не знает: запись самозамкнута в [ListeningLayout] (локальная
if (app.listening.value) { // стт), host-STT-события очки не обрабатывают (молча игнор в HostConnection).
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
return
}
log("stt", "ассистент: слушаю")
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 }
}
/** Click (кино): плей/пауза — как сегодня. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
private fun sendPhrase() {
log("gesture", "Click → отправить фразу")
sttMic.stop()
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = true
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
}
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
private fun startNewPhrase() {
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
val app = GlassesApp.instance
app.assistantText.value = ""
app.sttText.value = ""
app.thinking.value = false
app.answerScrollDelta.value = 0f
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
}
/**
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
*/
fun resumeSttListening() {
val app = GlassesApp.instance
app.thinking.value = false
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
log("stt", "возврат в «слушаю» (SttCancel)")
}
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
private fun cancelDictation() {
log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop()
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = false
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
}
}
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
/**
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
*/
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
private fun finishAssistantMode(reason: String) {
log("gesture", reason)
sttMic.stop()
val app = GlassesApp.instance
app.listening.value = false
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
}
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
private fun seekBy(seek: GestureDecision.Seek) {
log("gesture", "жест: свайп → ${seek.command}")
GlassesApp.instance.movieController.applyMediaCommand(
MediaCommandMsg(seek.command, seek.seekMs)
) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** /**
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
@@ -381,15 +239,15 @@ class MainActivity : BaseEventActivity() {
log("ui", "onPause") log("ui", "onPause")
val app = GlassesApp.instance val app = GlassesApp.instance
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл // Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым. // в лаунчер) режим общения гарантированно закрывается — стек POP-ится до фильма.
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) { if (app.navStack.value.size > 1) {
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)") log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
finishAssistantMode("onPause — потеря фокуса, выход из режима общения") app.assistantEngine.exitMode("onPause — потеря фокуса, выход из режима общения")
} }
val was = app.movieController.pauseIfPlaying() val was = app.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("onPause: pause wasPlaying=$was") logEvent("onPause: pause wasPlaying=$was")
app.movieController.ensureWifiLock() app.rootLayer.controller.ensureWifiLock()
app.hostConnection.sendPlaybackPositionNow() app.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас — пауза") if (was) log("ui", "экран погас — пауза")
} }
@@ -401,7 +259,7 @@ class MainActivity : BaseEventActivity() {
logEvent("onResume: resume flag=$resumeAfterScreenOn") logEvent("onResume: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
log("ui", "экран включён — продолжаю") log("ui", "экран включён — продолжаю")
} }
@@ -70,6 +70,8 @@ internal data class PlaybackSnapshot(
val playing: Boolean, val playing: Boolean,
val itemId: String?, val itemId: String?,
val audioIndex: Int, val audioIndex: Int,
/** Длительность видео, мс; -1 = неизвестно (плеера нет / ещё не prepared). */
val durationMs: Long = -1L,
) )
/** /**
@@ -239,10 +241,10 @@ class MovieController(private val context: Context) {
} }
} }
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */ /** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex, длительность). Только с главного потока. */
internal fun playbackPosition(): PlaybackSnapshot { internal fun playbackPosition(): PlaybackSnapshot {
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex) val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex)
return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex) return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex, player.duration)
} }
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */ /** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
@@ -2,39 +2,66 @@ package pw.binom.viewmate.glasses.gestures
import com.rayneo.arsdk.android.touch.TempleAction import com.rayneo.arsdk.android.touch.TempleAction
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.glasses.stt.SttModelStatus
/** /**
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md): * Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза». * «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
* *
* @NONE — кино: ассистент не вызван. * Навигация — ЛIFO-стек [NavFrame]: корень [NavFrame.Film] (двойным кликом не вытолкнуть),
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true). * сверху [NavFrame.Assistant] (чат), сверху [NavFrame.Listening] (микрофон, чат на фоне).
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText). * Двойной клик — POP верхнего кадра. Режим выводится из вершины стека + контента кадра
* @ANSWER — ответ виден: listening=false и assistantText непуст. * ассистента:
*
* @NONE — кино (вершина Film): ассистент не вызван.
* @LISTENING — вершина Listening: микрофон открыт, чат виден на фоне.
* @THINKING — вершина Assistant, фраза отправлена, ждём ответ (ShowText).
* @ANSWER — вершина Assistant, ответ виден (assistantText непуст).
* @IDLE — вершина Assistant без «думаю»/ответа: микрофон закрыт, чат на месте.
*/ */
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, } enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, IDLE, }
/** Кадр стека навигации очков. Снизу вверх: [Film] (корень) → [Assistant] → [Listening]. */
enum class NavFrame { Film, Assistant, Listening }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */ /** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState( data class AssistantState(
/** Микрофон открыт (TripleClick → слушаю). */ /** Стек навигации, снизу вверх. Внизу всегда [NavFrame.Film] (корень). */
val listening: Boolean = false, val stack: List<NavFrame> = listOf(NavFrame.Film),
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */ /** «Думаю»: фраза отправлена, ждём ответ (ShowText). Контент кадра ассистента. */
val thinking: Boolean = false, val thinking: Boolean = false,
/** Накопленные фразы (SttPhrase, full). */ /** Живой текст записи (локальное распознавание в [ListeningLayout]). Контент кадра «слушаю». */
val sttText: String = "", val sttText: String = "",
/** Ответ ассистента (ShowText). */ /** Ответ ассистента (ShowText). Контент кадра ассистента. */
val assistantText: String = "", val assistantText: String = "",
) /** Попытка начать диктовку отклонена: модель не скачалась (микрофон не открыт). */
val sttStartBlocked: Boolean = false,
/** Последний стоп/отправка не поймала голоса — показать «не расслышал». */
val sttNoSpeech: Boolean = false,
) {
/** Вершина стека — текущий кадр. */
val top: NavFrame get() = stack.last()
/** Микрофон открыт: вершина — [NavFrame.Listening]. */
val listening: Boolean get() = top == NavFrame.Listening
/** В кадре ассистента (idle/думаю/ответ/слушаю): вершина Assistant или Listening. */
val inAssistant: Boolean get() = top == NavFrame.Assistant || top == NavFrame.Listening
}
/** /**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст), * Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт — * он приоритетнее «думаю»; вершина [NavFrame.Listening] всегда побеждает (микрофон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит). * жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*
* Стек: фильм (корень) → ассистент → слушаю. «Ассистент» без «слушаю»/ответа/«думаю»
* (вершина [NavFrame.Assistant]) — idle-кадр [IDLE]: микрофон закрыт, чат не прерван.
*/ */
fun assistantMode(state: AssistantState): AssistantMode = when { fun assistantMode(state: AssistantState): AssistantMode = when {
state.listening -> AssistantMode.LISTENING state.top == NavFrame.Listening -> AssistantMode.LISTENING
state.assistantText.isNotBlank() -> AssistantMode.ANSWER state.top == NavFrame.Assistant -> when {
state.thinking -> AssistantMode.THINKING state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
else -> AssistantMode.NONE else -> AssistantMode.NONE
} }
@@ -44,8 +71,9 @@ fun assistantMode(state: AssistantState): AssistantMode = when {
* *
* | Режим | Клик | Двойной | Тройной | Свайпы | * | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------| * |------------------|--------------------|-----------|---------|----------------------|
* | Кино | плей/пауза | игнор | вызов | ±30с | * | Кино (корень) | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | отмена | игнор | игнор | * | Слушаю | отправить фразу | стоп → idle | игнор | игнор |
* | Ассистент (idle) | открыть запись | выход | запись | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл | * | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/ */
sealed interface GestureDecision { sealed interface GestureDecision {
@@ -64,10 +92,18 @@ sealed interface GestureDecision {
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */ /** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
data object NewPhrase : GestureDecision data object NewPhrase : GestureDecision
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */ /**
data object CancelDictation : GestureDecision * Двойной в «слушаю» (стоп, POP верхнего кадра): закрыть микрофон, ОСТАТЬСЯ в ассистенте
* (стек: фильм → ассистент, без «слушаю» сверху) — см. [AssistantState.stopListeningFrame].
* Выхода нет: клик открывает запись снова, двойной — выход из чата. Сторона очков:
* StopStt(cancel=true).
*/
data object StopListening : GestureDecision
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */ /** Клик/тройной в idle-кадре ассистента: снова открыть микрофон (вниз по стеку — «слушаю»). */
data object BeginListening : GestureDecision
/** Двойной в «думаю»/«ответ виден»/idle: выход из режима — закрыть оверлей, фильм на паузе. */
data object ExitMode : GestureDecision data object ExitMode : GestureDecision
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */ /** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
@@ -81,15 +117,11 @@ sealed interface GestureDecision {
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android. * Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
* *
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]). * @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
* меняем знак здесь, в одной точке).
*/ */
fun gestureDecision( fun gestureDecision(
action: TempleAction, action: TempleAction,
state: AssistantState, state: AssistantState,
seekStepMs: Long = 30_000, seekStepMs: Long = 30_000,
pageDeltaPx: Float = 200f,
): GestureDecision = when (val mode = assistantMode(state)) { ): GestureDecision = when (val mode = assistantMode(state)) {
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице. // Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
AssistantMode.NONE -> when (action) { AssistantMode.NONE -> when (action) {
@@ -100,9 +132,30 @@ fun gestureDecision(
?: GestureDecision.Ignore ?: GestureDecision.Ignore
} }
AssistantMode.LISTENING -> when (action) { // «Слушаю» (вершина — Listening). Модель не скачалась (стtStartBlocked): микрофон не открыт,
is TempleAction.Click -> GestureDecision.SendPhrase // кадр на месте — Click = повторить попытку, DoubleClick = выход. Иначе: Click — отправить,
is TempleAction.DoubleClick -> GestureDecision.CancelDictation // DoubleClick — стоп (POP «слушаю» → idle).
AssistantMode.LISTENING -> when {
state.sttStartBlocked -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
else -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase
// Стоп: POP «слушаю» → кадр ассистента (idle), чат не прерван.
is TempleAction.DoubleClick -> GestureDecision.StopListening
else -> GestureDecision.Ignore
}
}
// Idle-кадр ассистента (стек: фильм → ассистент, без «слушаю»): микрофон закрыт.
AssistantMode.IDLE -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.TripleClick -> GestureDecision.BeginListening
// Выход из чата: POP «ассистент» → корень (кино).
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore else -> GestureDecision.Ignore
} }
@@ -113,35 +166,84 @@ fun gestureDecision(
} }
AssistantMode.ANSWER -> when (action) { AssistantMode.ANSWER -> when (action) {
// Плавной и дискретной прокрутки ответа больше нет (решение пользователя): свайпы
// игнорируем — из-под ответа не ищем по (приостановленному) фильму и не скроллим текст.
is TempleAction.Click -> GestureDecision.NewPhrase is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode is TempleAction.DoubleClick -> GestureDecision.ExitMode
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
is TempleAction.SlideContinuous ->
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
// два строка выше.
GestureDecision.ScrollAnswer(action.delta)
else -> GestureDecision.Ignore else -> GestureDecision.Ignore
} }
} }
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */ /**
* Тройной клик в фильме: на стек кладём кадр ассистента (чат) и сразу кадр «слушаю»
* (микрофон) — стек [Film, Assistant, Listening]. Чат виден на фоне. Чистая функция.
*/
fun AssistantState.enterAssistant(): AssistantState =
copy(
stack = stack + NavFrame.Assistant + NavFrame.Listening,
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Клик в idle/«думаю»/«ответ»: кладём кадр «слушаю» поверх кадра ассистента. Чистая функция. */
fun AssistantState.beginListeningFrame(): AssistantState =
copy(stack = stack + NavFrame.Listening, thinking = false, sttText = "", sttNoSpeech = false)
/**
* Стоп-записи ([GestureDecision.StopListening], двойной в «слушаю» / пустой клик): POP кадра
* «слушаю» — остаёмся в кадре ассистента (idle), чат НЕ прерван, микрофон закрыт. [noSpeech] —
* показать «не расслышал». Возобновить запись — Click/Triple ([GestureDecision.BeginListening]);
* выйти — DoubleClick ([GestureDecision.ExitMode]). Чистая функция — unit-тесты без Android.
*/
fun AssistantState.stopListeningFrame(noSpeech: Boolean): AssistantState =
copy(stack = stack.dropLast(1), thinking = false, sttText = "", sttNoSpeech = noSpeech)
/**
* Выход из чата ([GestureDecision.ExitMode], двойной в кадре ассистента / таймаут тишины):
* POP кадра ассистента до корня [NavFrame.Film] (фильм). Сброс контента. Чистая функция.
*/
fun AssistantState.exitToFilm(): AssistantState =
copy(
stack = listOf(NavFrame.Film),
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Оверлей общения виден, пока в кадре ассистента (idle/думаю/ответ/слушаю) или блок модели. */
fun overlayVisible(state: AssistantState): Boolean = fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank() state.inAssistant || state.sttStartBlocked
/** /**
* Заголовок оверлея (над ответом). null — только ответ. * Заголовок оверлея (над ответом). null — только ответ.
* - «модель не скачалась» — попытка начать диктовку отклонена (модель не READY),
* микрофон не открыт; ниже живая строка прогресса ([modelStatusLine]);
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если * - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт); * listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт; * - «слушаю...»/фраза — микрофон открыт;
* - при «ответе виден» — последняя фраза как контекст (или null). * - «не расслышал» — стоп/отправка не поймала голоса (пустой клик);
* - кадр ассистента (idle/думаю без ответа/ответ виден) — подсказка жестов
* (клик — говорить, двойной — выход); чат прокручивается под заголовком.
*/ */
fun overlayHeader(state: AssistantState): String? = when { fun overlayHeader(state: AssistantState): String? = when {
state.sttStartBlocked -> "модель не скачалась"
state.thinking && state.assistantText.isBlank() -> "думаю..." state.thinking && state.assistantText.isBlank() -> "думаю..."
state.listening -> state.sttText.ifBlank { "слушаю..." } state.top == NavFrame.Listening -> state.sttText.ifBlank { "слушаю..." }
state.sttNoSpeech -> "не расслышал — клик, чтобы повторить"
state.top == NavFrame.Assistant -> "клик — говорить · двойной — выход"
else -> state.sttText.takeIf { it.isNotBlank() } 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]. * Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue]. * [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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -18,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import pw.binom.viewmate.glasses.GlassesApp
/** /**
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content, * Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
@@ -36,12 +38,20 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
val d = data ?: return val d = data ?: return
val baseStyle = MaterialTheme.typography.bodyMedium val baseStyle = MaterialTheme.typography.bodyMedium
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale) val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
// + свежее значение от телефона); без видео — ничего не выводить.
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
val movieActive = GlassesApp.instance.rootLayer.controller.movieActive.collectAsState()
Row( Row(
modifier = modifier modifier = modifier
.background(Color(0x99000000)) // полупрозрачный чёрный .background(Color(0x99000000)) // полупрозрачный чёрный
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale), .padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (movieActive.value && diffMs.value != null) {
Text("${diffMs.value} мс", color = Color(0xFF999999), style = style)
Spacer(Modifier.width(6.dp * scale))
}
Text(d.time, color = Color.White, style = style) Text(d.time, color = Color.White, style = style)
Spacer(Modifier.width(6.dp * scale)) Spacer(Modifier.width(6.dp * scale))
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика. // Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
@@ -0,0 +1,43 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import pw.binom.asr.qwen3.Qwen3Config
import pw.binom.asr.qwen3.createQwen3Sherpa
import pw.binom.viewmate.glasses.log
/**
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
* [SttModelDownloader]).
*/
class GlassesAsr(
modelsDir: File,
numThreads: Int = 4,
) {
private val modelsDir = modelsDir
private val numThreads = numThreads
/**
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
*/
private val recognizer by lazy {
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
createQwen3Sherpa(
Qwen3Config(
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
numThreads = numThreads,
),
)
}
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
runCatching { recognizer.recognize(samples, sampleRate) }
.onFailure { log("stt", "Qwen3 recognizer упал: ${it.javaClass.simpleName}: ${it.message}") }
.getOrDefault("")
}
@@ -0,0 +1,31 @@
package pw.binom.viewmate.glasses.stt
/**
* Абстракция механизма распознавания очков: готов ли механизм + распознать фразу.
*
* [GlassesSttSession] работает с этим интерфейсом, а не с конкретным движком — так
* дальше за ним можно подключить другой механизм (например, удалённое распознавание
* на телефоне) без изменения сессии.
*/
interface GlassesRecognizer {
/** Механизм готов (модель докачана / нативка загрузится). false — не открывать микрофон. */
fun isReady(): Boolean
/** Распознать фразу (FloatArray, 16 кГц mono). Пустая строка — тишина / не готово. */
fun recognize(phrase: FloatArray): String
}
/**
* Единственная реализация: локальный механизм распознавания очков — Qwen3-ASR
* ([GlassesAsr]); готовность берёт из статуса скачанной модели ([SttModelDownloader]).
* Телефон в распознавании не участвует.
*/
class LocalGlassesRecognizer(
private val asr: GlassesAsr,
private val model: SttModelDownloader,
) : GlassesRecognizer {
override fun isReady(): Boolean = model.status.value.ready
override fun recognize(phrase: FloatArray): String = asr.recognize(phrase)
}
@@ -0,0 +1,154 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.sqrt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.stt.s16leToFloat
import pw.binom.viewmate.core.stt.splitPhrases
import pw.binom.viewmate.core.stt.trimEdges
import pw.binom.viewmate.glasses.log
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
interface GlassesMic {
fun start(filesDir: File, shouldRun: () -> Boolean)
fun stop()
}
/**
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
* локально (через [recognizer]) и возвращаем готовый текст — телефон больше
* не участвует в распознавании. UX остаётся «говори → клик → текст».
*
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
* пусто после 30s-лимита (timeout) — автозавершение.
*/
class GlassesSttSession(
private val filesDir: File,
private val scope: CoroutineScope,
private val recognizer: GlassesRecognizer,
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
) {
/**
* Обработчик готового текста ([finish] → [onResult] на главном потоке). Сессия лишь
* *порождает* результат — куда он уходит, решает владелец: слой записи
* (ui.layers.ListeningLayout) регистрирует в onEnter, сбрасывает в onExit
* (`onResult = null` — слоя нет → результата некому принять).
*/
var onResult: ((text: String, auto: Boolean) -> Unit)? = null
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
private val sessionLimitMs = 30_000L
private var mic: GlassesMic? = null
/**
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
*/
private val pcmLock = ReentrantLock()
private var pcm = ByteArray(0)
private var watchdog: Job? = null
@Volatile
private var running = false
val isRunning: Boolean get() = running
/**
* Начать слушать: чистый буфер + микрофон + таймер автозавершения.
* Гейт в момент ПОПЫТКИ начать сбор: если механизм ещё не готов ([GlassesRecognizer.isReady]=false),
* микрофон НЕ открываем и PCM не накапливаем — сбор не начинается, распознавание
* гарантированно не будет пустым «потом». Ошибку показывает UI (sttStartBlocked).
*/
fun start() {
if (running) return
if (!recognizer.isReady()) {
log("stt", "STT-сессия очков: механизм не готов — микрофон не открываю, сбор не начинаю")
return
}
running = true
pcmLock.withLock { pcm = ByteArray(0) }
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
mic = m
m.start(filesDir) { running }
watchdog = scope.launch {
delay(sessionLimitMs)
finish(auto = true)
}
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
}
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
fun send() = finish(auto = false)
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
fun cancel() {
stopMic()
watchdog?.cancel()
pcmLock.withLock { pcm = ByteArray(0) }
log("stt", "STT-сессия очков: отмена")
}
/** Полный стоп (выход из режима ассистента). */
fun stop() {
running = false
stopMic()
watchdog?.cancel()
pcmLock.withLock { pcm = ByteArray(0) }
log("stt", "STT-сессия очков: закрыта")
}
private fun finish(auto: Boolean) {
if (!running) return
running = false
stopMic()
watchdog?.cancel()
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
scope.launch(Dispatchers.Default) {
val text = if (audio.isNotEmpty() && recognizer.isReady()) {
val floats = s16leToFloat(audio)
var peak = 0f
for (f in floats) { val a = if (f < 0f) -f else f; if (a > peak) peak = a }
val win = 320
var w005 = 0; var w001 = 0
var i = 0
while (i + win <= floats.size) {
var sum = 0.0
for (k in i until i + win) sum += floats[k] * floats[k].toDouble()
val rms = sqrt(sum / win).toFloat()
if (rms >= 0.005f) w005++
if (rms >= 0.01f) w001++
i += win
}
val trimmed = trimEdges(floats)
val phrases = splitPhrases(trimmed)
log("stt", "diag pcm=${audio.size / 32}мс peak=${peak} окно[≥.005]=${w005} [≥.01]=${w001} trim=${trimmed.size / 32000}с фраз=${phrases.size}")
val joined = phrases.mapIndexed { n, p ->
val r = recognizer.recognize(p)
log("stt", "diag фраза${n + 1} ${p.size * 1000 / 16000}мс → '${r.take(40)}'")
r
}.joinToString(" ")
if (joined.isEmpty()) log("stt", "diag: результат пустой")
joined
} else {
log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено")
""
}
withContext(Dispatchers.Main) { onResult?.invoke(text, auto) }
}
}
private fun stopMic() {
mic?.stop()
mic = null
}
}
@@ -30,13 +30,13 @@ import kotlin.math.roundToInt
class SttMicStream( class SttMicStream(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val onChunk: suspend (ByteArray) -> Unit, private val onChunk: suspend (ByteArray) -> Unit,
) { ) : GlassesMic {
private var job: Job? = null private var job: Job? = null
/** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */ /** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */
private var dump: WavWriter? = null private var dump: WavWriter? = null
fun start(filesDir: File, shouldRun: () -> Boolean) { override fun start(filesDir: File, shouldRun: () -> Boolean) {
job?.cancel() job?.cancel()
val minBuffer = AudioRecord.getMinBufferSize( val minBuffer = AudioRecord.getMinBufferSize(
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
@@ -96,7 +96,7 @@ class SttMicStream(
} }
} }
fun stop() { override fun stop() {
job?.cancel() job?.cancel()
job = null job = null
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла) // dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
@@ -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)
}
}
@@ -32,26 +32,51 @@ import pw.binom.viewmate.glasses.ChatEntry
import pw.binom.viewmate.glasses.GlassesApp import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.gestures.AssistantState import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.overlayHeader 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.overlayVisible
import pw.binom.viewmate.glasses.gestures.scrollOffset import pw.binom.viewmate.glasses.gestures.scrollOffset
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.ListeningLayout
/** /**
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку * Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md). * каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок, * «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
* в том же scale-контейнере, что у экранов. Режим общения явный
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
* Состояния: «слушаю»/«думаю...» — заголовок,
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов — * «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
* [GlassesApp.answerScrollDelta]). * [GlassesApp.answerScrollDelta]).
*/ */
@Composable @Composable
fun AssistantOverlay() { fun AssistantOverlay(engine: AssistantEngine) {
val app = GlassesApp.instance val app = GlassesApp.instance
val listening by app.listening.collectAsState() // Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine].
val thinking by app.thinking.collectAsState() val navStack by app.navStack.collectAsState()
val sttText by app.sttText.collectAsState() val thinking by engine.thinking.collectAsState()
val assistantText by app.assistantText.collectAsState() // Живой текст распознавания владеет слой записи ([ListeningLayout] — единственный, кто в курсе
val chatHistory by app.chatHistory.collectAsState() // микрофона): читаю из верхнего слоя, не из движка. В локальном режиме он vestigial (заполняется
val state = AssistantState(listening, thinking, sttText, assistantText) // на результат и скрывается сразу за POP-ом слоя), поэтому реактивность не критична — значение
// перечитывается на каждую рекомпозицию (нав-стек + статусы ассистента выше).
val sttText = (app.layoutManager.current as? ListeningLayout)?.sttText?.value.orEmpty()
val assistantText by engine.assistantText.collectAsState()
val sttNoSpeech by engine.sttNoSpeech.collectAsState()
val chatHistory by engine.chatHistory.collectAsState()
// «Модель не скачалась» выводится из состояния догрузчика (отдельного логического флага в
// движке нет): распознавание локально, поэтому модель не READY = распознавание не готово —
// объясняем что происходит (качается / не скачалась).
val modelStatus by app.stt.status.collectAsState()
val state = AssistantState(
stack = navStack,
thinking = thinking,
sttText = sttText,
assistantText = assistantText,
sttStartBlocked = !modelStatus.ready,
sttNoSpeech = sttNoSpeech,
)
if (!overlayVisible(state)) return if (!overlayVisible(state)) return
// Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ модель не READY («не скачалась»).
val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды // Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать // (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
// в единственном экземпляре — иначе глаза скроллят по-разному. // в единственном экземпляре — иначе глаза скроллят по-разному.
@@ -66,28 +91,35 @@ fun AssistantOverlay() {
} }
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза; // Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset]. // аккумулятор (answerScrollDelta) общий в [engine]. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
app.answerScrollDelta.collect { pending -> engine.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue) val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
app.answerScrollDelta.value = 0f engine.answerScrollDelta.value = 0f
if (target != scrollState.value) { if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150)) scrollState.animateScrollTo(target, tween(150))
} }
} }
} }
overlayBox(app, state, chatHistory, scrollState) overlayBox(app, state, chatHistory, scrollState, modelLine)
} }
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */ /**
* Поглазник: затемняющий слой накрывает ВСЁ поле глаза (100% viewport)
* непрозрачным чёрным — поверх SurfaceView-видео и любого положения регулятора
* «Высота» (TZ-ui-four п.4: 72%-полупрозрачный слой поверх контейнера видео
* оставался «проглядывающим» — видео светилось/не затемнялось). Контент
* (текст/редактирование) остаётся в том же scale/offset-контейнере, что у экранов.
*/
@Composable @Composable
private fun overlayBox( private fun overlayBox(
app: GlassesApp, app: GlassesApp,
state: AssistantState, state: AssistantState,
chatHistory: List<ChatEntry>, chatHistory: List<ChatEntry>,
scrollState: ScrollState, scrollState: ScrollState,
modelLine: String?,
) { ) {
Binocular(scale = false) { Binocular(scale = false) {
val scale by app.scale.collectAsState() val scale by app.scale.collectAsState()
@@ -98,24 +130,31 @@ private fun overlayBox(
val containerH = (screenHeightPx * scale).toInt() val containerH = (screenHeightPx * scale).toInt()
Box( Box(
modifier = Modifier modifier = Modifier
.width(containerW.px) .fillMaxSize()
.height(containerH.px) // Полупрозрачный: затемнённый фильм (DimScrim снизу) просвечивает позади чата —
.offset( // «чат виден на фоне». Чат сам по себе тёмный, читаемость не падает.
x = scaleOffsetX(side, screenWidthPx, scale).px, .background(Color.Black.copy(alpha = 0.88f)),
// вертикаль (vertical) — только для видео; оверлей всегда у верха
)
.background(Color.Black.copy(alpha = 0.72f)),
) { ) {
AssistantOverlayContent(state, chatHistory, scrollState) 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) { if (state.listening && !state.sttStartBlocked) {
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(top = 12.dp, end = 12.dp) .padding(top = 12.dp, end = 12.dp)
.size(16.dp) .size(16.dp)
.background(Color(0xFFFF3B30), CircleShape), .background(Color(0xFFFF3B30), CircleShape),
) )
}
} }
} }
} }
@@ -126,6 +165,7 @@ private fun AssistantOverlayContent(
state: AssistantState, state: AssistantState,
chatHistory: List<ChatEntry>, chatHistory: List<ChatEntry>,
scrollState: ScrollState, scrollState: ScrollState,
modelLine: String?,
) { ) {
val hasHistory = chatHistory.isNotEmpty() val hasHistory = chatHistory.isNotEmpty()
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank() val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
@@ -146,6 +186,17 @@ private fun AssistantOverlayContent(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }
// Строка статуса модели (только «слушаю» + модель не READY) — объясняет, почему
// распознавание сейчас не работает и что происходит (качается / не скачалась).
if (!modelLine.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = modelLine,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
}
if (hasHistory) { if (hasHistory) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Column( Column(
@@ -0,0 +1,42 @@
package pw.binom.viewmate.glasses.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.LayoutManager
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
/**
* Ленточный (LIFO) layout-стек очков: композитинг снизу вверх — [LayoutManager.compositing]
* рендерит ВСЕ слои (кино → затемнение → чат → слушаю), панель статуса у верхнего.
* Стек правды — персистентный [GlassesApp.layoutManager]; [navStack] — лишь его зеркало
* (для триггера рекомпозиции).
*/
@Composable
fun LayoutStackContent(manager: LayoutManager) {
// Триггер рекомпозиции: стек менеджера меняется через ctx.open/close, а LayoutManager.onChange
// зеркалирует его в [GlassesApp.navStack] (StateFlow). compositing() читает сам стек менеджера
// (deque); изменение navStack запускает рекомпозицию.
val nav by GlassesApp.instance.navStack.collectAsState()
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
manager.compositing().forEach { layer ->
layer.layout.drawStage()
// Затемняем фильм, только когда поверх него что-то есть (чтение nav — и триггер).
if (layer.layout is MoveLayout && nav.size > 1) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
)
}
}
manager.current.drawStatusPanel()
}
}
@@ -0,0 +1,134 @@
package pw.binom.viewmate.glasses.ui.layers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import pw.binom.viewmate.core.protocol.SttText
import pw.binom.viewmate.glasses.ChatEntry
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.gestures.NavFrame
/**
* Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные
* flows в [GlassesApp]. Держит ТОЛЬКО диалог ассистента с телефоном ([thinking]/[assistantText]/
* [chatHistory]/[answerScrollDelta]) и его реакции на ввод ([continueTurn]/[stopToIdle]).
*
* Микрофон и распознавание движку НЕ ЗНАЕТ: всё стт живёт в [ListeningLayout] (единственный, кто
* в курсе микрофона и распознавания); для движка ввод — просто «текст пришёл» ([continueTurn]) или
* «ввод отменён» ([stopToIdle]). Чат-оверлей читает стейт тоже отсюда.
*
* Зависимости (протокол, менеджер стека) берёт лениво из [GlassesApp] в методах — сам движок
* строится без побочных эффектов (его стейт — лишь flows), порядок инициализации полей не критичен.
*/
class AssistantEngine(private val app: GlassesApp) {
// ── Стейт диалога ассистента (был размазанными полями в GlassesApp) ───────────────
/** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */
val thinking = MutableStateFlow(false)
/** Текст ответа ассистента (ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/**
* У ассистента «не расслышал»: последний ввод отменили без голоса — оверлей показывает
* «не расслышал, тапни ещё раз» (idle-hint). Это стейт *отображения* ассистента (факт
* отмены несёт [stopToIdle]), не стт-факт — распознанным текстом движок не владеет.
*/
val sttNoSpeech = MutableStateFlow(false)
/** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */
val answerScrollDelta = MutableStateFlow(0f)
/** Скоуп корутин, которые запускают вызывающие слои (явное ожидание результата записи). */
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// ── Под-состояние кадра ассистента (top == Assistant) ─────────────────────────────
/** idle / thinking / answer — по стейту (top кадра ассистента предполагается активным). */
fun mode(): AssistantMode = when {
assistantText.value.isNotBlank() -> AssistantMode.ANSWER
thinking.value -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
// ── Эффекты (перенесены из GlassesGestureEffects) ────────────────────────────────
/**
* Кадр ассистента открыт ([AssistantLayout.onEnter]) — чистый старт сессии: стираем прошлый
* ответ / «не расслышал» / дельту скролла. Паузу фильма и синх позиций делает ВЛАДЕЛЕЦ (слой
* кино), а не движок: здесь чистится только *свой* стейт.
*/
fun beginSession() {
sttNoSpeech.value = false
assistantText.value = ""
answerScrollDelta.value = 0f
}
/** Логика «думает ассистент или нет»: сотереть показанный ответ и дельту скролла перед новой фразой. */
fun clearAnswer() {
assistantText.value = ""
answerScrollDelta.value = 0f
}
/**
* Ввод принят ([ListeningLayout.onSuccess] → вызывающий [AssistantLayout]): жду ответ
* ([thinking]). Слой записи сам себя POP-нул (ctx.close), его onExit закрыл микрофон —
* движок микрофоном не владеет, стек здесь не трогаем. «Продолжить ход» диалога с
* телефоном = отправить распознанный ввод на LLM ([SttText]) и ожидать ответ.
*/
suspend fun continueTurn(text: String) {
log("stt", "ассистент: фраза «${text.take(40)}» → на телефон (LLM), жду ответ")
app.hostConnection.sendToHost(SttText(text))
thinking.value = true
}
/**
* Ввод отменён ([ListeningLayout.onCancel] → вызывающий [AssistantLayout]): стоп, остаюсь в
* ассистенте (idle). [sttNoSpeech] — idle-hint «не расслышал» (факт отмены, не стт-текст).
*/
fun stopToIdle(reason: String, noSpeech: Boolean) {
log("stt", reason)
thinking.value = false
sttNoSpeech.value = noSpeech
}
/** Выход из режима в корень: closeToRoot (слои POP-нуты, их onExit сам закроет своё), чистим стейт. */
fun exitMode(reason: String) {
log("stt", reason)
app.layoutManager.closeToRoot()
thinking.value = false
sttNoSpeech.value = false
assistantText.value = ""
answerScrollDelta.value = 0f
}
// ── Host-driven записи стейта (вместо прямого писания flows в GlassesApp) ──────────
/** ShowText: ответ ассистента виден, «думаю» сброшено. */
fun onShowText(text: String) {
thinking.value = false
answerScrollDelta.value = 0f
assistantText.value = text
}
/** ChatHistory: история сессии с телефона (фильтрация wire-типов — в HostConnection). */
fun onChatHistory(messages: List<ChatEntry>) {
chatHistory.value = messages
}
}
/** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */
fun Layout.toFrame(): NavFrame = when (this) {
is MoveLayout -> NavFrame.Film
is AssistantLayout -> NavFrame.Assistant
is ListeningLayout -> NavFrame.Listening
else -> NavFrame.Film
}
@@ -0,0 +1,86 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.runtime.Composable
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.stt.GlassesStt
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import kotlin.coroutines.resume
/**
* Кадр ассистента (чат). Полупрозрачный [AssistantOverlay] — затемнённый фильм просвечивает
* на фоне (фильм остаётся ниже в композите). В под-состоянии «слушаю» оверлей сам показывает
* индикатор записи и текущую фразу — отдельного экрана «слушаю» нет, чат остаётся на фоне.
*
* В кадре остаётся только *его* логика: скролы (свайпы), «думает ассистент или нет» и реакция на
* результат записи. Записью микрофона кадр НЕ занимается: клик *вызывает* слой записи
* ([ListeningLayout]) suspend-функцией [listen] и явно ждёт её окончания (текст / отмена);
* сам микрофон запись открывает/закрывает в своём onEnter/onExit.
*/
class AssistantLayout(
private val ctx: LayoutContext,
private val engine: AssistantEngine,
private val stt: GlassesStt,
) : Layout {
@Composable
override fun drawStage() {
AssistantOverlay(engine)
}
@Composable
override fun drawStatusPanel() = Unit
/** Кадр ассистента открыт: чистый старт сессии — стираем прошлый ответ / «не расслышал» / дельту скролла. */
override fun onEnter() {
engine.beginSession()
}
/**
* Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк);
* логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ).
*/
override fun gesture(g: Gesture) {
when (g) {
Gesture.DoubleClick -> engine.exitMode("DoubleClick → выход из чата (кино остаётся на паузе)")
Gesture.Click -> when (engine.mode()) {
AssistantMode.IDLE -> openListening()
AssistantMode.THINKING, AssistantMode.ANSWER -> {
engine.clearAnswer()
openListening()
}
else -> Unit // NONE/LISTENING при top==Assistant невозможны (тогда топ был бы другим)
}
Gesture.TripleClick -> if (engine.mode() == AssistantMode.IDLE) openListening()
Gesture.SwipeForward, Gesture.SwipeBackward -> Unit // логика скролов: в диалоге не скроллим
}
}
/**
* Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания.
* Возвращает распознанный текст ([onSuccess]) или null ([onCancel], любая отмена) —
* слой записи завершает ожидание этими кэлбэками.
*/
private suspend fun listen() = ListeningLayout.listen(ctx, stt)
/** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */
private fun openListening() {
engine.scope.launch {
val text = listen()
if (text != null) {
engine.continueTurn(text) // распознали фразу → жду ответ (thinking); слой записи сам себя POP-нул
} else {
engine.stopToIdle(
"отмена ввода → остаюсь в ассистенте",
noSpeech = true
) // отмена → idle
}
}
}
}
@@ -0,0 +1,140 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.stt.GlassesStt
import kotlin.coroutines.resume
/**
* Слой записи — ЕДИНСТВЕННЫЙ, кто знает про микрофон и распознавание. Он сам открывает
* микрофон ([stt.session.start] в [onEnter]), закрывает ([onExit]) и сам принимает
* стт-решение: клик распознаёт накопленное ([stt.session.send]), локальный результат
* ([onSttResult]) слой переваривает сам и сообщает вызывающему факт исхода кэлбэками
* [onSuccess]/[onCancel]. Хостовых входов нет — в локальной архитектуре слой самозамкнут.
*
* Для всего остального (кино, чат, ассистент) «текст откуда-то берётся» — никто кроме этого
* слоя не в курсе микрофона и распознавания. Зависимость — единый объект [GlassesStt] (весь
* стек распознавания: скачивание, статус, сессия микрофона), а не весь [GlassesApp];
* исхода двух видов: успех — [onSuccess] с текстом, ЛЮБАЯ отмена (двойной тап, тишина,
* модель не готова) — [onCancel]; выхода из режима слой не делает — это уже эффект вызывающего.
*/
class ListeningLayout(
private val ctx: LayoutContext,
private val stt: GlassesStt,
private val onSuccess: (String) -> Unit,
private val onCancel: () -> Unit,
) : Layout {
companion object {
suspend fun listen(
ctx: LayoutContext,
stt: GlassesStt,
) = suspendCancellableCoroutine { context ->
ctx.open { c ->
context.invokeOnCancellation {
c.close()
}
ListeningLayout(c, stt, onSuccess = { text ->
context.resume(text)
}, onCancel = {
context.resume(null)
})
}
}
}
/** Накопленный распознанный текст — живой стейт слоя (оверлей читает через вершину стека). */
val sttText = MutableStateFlow("")
private val modelReady: Boolean get() = stt.status.value.ready
/** Прозрачный слой: сам ничего не рисует — оверлей ассистента (поверх) показывает индикатор и фразу. */
@Composable
override fun drawStage() = Unit
@Composable
override fun drawStatusPanel() = Unit
/** Слой открыт: открыть микрофон (само гейтится готовностью модели: не ready → старт молчит). */
override fun onEnter() {
sttText.value = ""
// Регистрирую себя обработчиком готового текста: слой сам заводит свой результат,
// сессия лишь порождает текст — куда он идёт, знает только владелец (этот слой).
stt.session.onResult = { text, auto -> onSttResult(text, auto) }
stt.session.start()
log("stt", "слои: вхожу в режим записи, стартую сессию + микрофон")
}
/** Слой закрыт: микрофон закрыть + безопасная отмена ожидания (resume однократен, повтор — no-op). */
override fun onExit() {
stt.session.onResult = null
stt.session.stop()
onCancel()
}
/**
* Жесты слоя: клик — распознать накопленное (модель не ready — повторить попытку старта);
* двойной — отмена (стоп, остаюсь в ассистенте) или выход (модель не скачалась). Остальное — игнор.
*/
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click ->
if (modelReady) {
log("stt", "клик → распознаю накопленное")
stt.session.send()
} else {
log("stt", "клик: модель не готова — повторю старт записи")
stt.session.start()
}
Gesture.DoubleClick ->
if (modelReady) {
log("stt", "двойной → отмена (стоп, остаюсь в ассистенте)")
onCancel()
ctx.close()
} else {
log("stt", "двойной: модель не скачалась → отмена, остаюсь в ассистенте")
onCancel()
ctx.close()
}
Gesture.TripleClick,
Gesture.SwipeForward,
Gesture.SwipeBackward -> Unit
}
}
/**
* Обработчик результата ЛОКАЛЬНОГО распознавания. Слой сам заводит свой результат:
* регистрируется на [stt.session.onResult] в [onEnter], сбрасывает в [onExit] —
* извне (телефон/LLM-путь) [onSttResult] не вызывают. Слой сам решает исход:
* - текст не пуст → успех ([onSuccess]) + POP слоя;
* - пусто + авто (тишина 30с) → отмена ([onCancel]) + POP слоя;
* - пусто + клик (без голоса) → отмена ([onCancel]) + POP слоя (не расслышал).
* Текст дальше «уходит от куда-то» (в LLM на телефон) — слой это не видит.
*/
private fun onSttResult(text: String, auto: Boolean) {
sttText.value = text
when {
text.isNotBlank() -> {
log("stt", "распознал: «$text» → отправляю ассистенту")
onSuccess(text)
ctx.close()
}
else -> {
log(
"stt",
"без голоса (${if (auto) "тишина 30с" else "пустой клик"}) → отмена, остаюсь в ассистенте"
)
onCancel()
ctx.close()
}
}
}
}
@@ -0,0 +1,90 @@
package pw.binom.viewmate.glasses.ui.layers
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.glasses.ConnectionScreen
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.MovieController
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
/**
* Кино — корень стека. Рисуется всегда (нижний слой композита): активный фильм —
* [GlassesMovieScreen], иначе экран подключения [ConnectionScreen]. Поверх него LayoutManager
* кладёт затемнение + под-слои (ассистент/слушание), поэтому фильм виден на фоне чата.
*
* Слой *владеет* своим медиастейтом ([controller]: плеер/позиция/wifi-lock) — он живёт со
* стопом корня, а не с жизненным циклом Activity. Протокол/lifecycle/эффекты дотягиваются до
* движка через слой ([controller]). Слой *сам* отвечает за свои жесты: тройной клик — пауза +
* открыть ассистента, клик — плей/пауза, свайпы — seek. Открытие под-слоёв — через [ctx].
*/
class MoveLayout(
private val ctx: LayoutContext,
context: Context,
) : Layout {
/** Медиадвижок слоя: [MoveLayout] — владелец (корень живёт до конца сессии очков). */
val controller = MovieController(context)
@Composable
override fun drawStage() {
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) GlassesMovieScreen(player = player, title = title)
else ConnectionScreen()
}
@Composable
override fun drawStatusPanel() = Unit
/** Жесты кино (слой активен, когда он вершина стека): каждый жест ведёт себя сам. */
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click -> togglePlayback()
Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore)
Gesture.TripleClick -> callAssistant()
Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000)
Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000)
}
}
/** Click (кино): плей/пауза + отправить позицию на телефон. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
controller.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** Свайп (кино): seek на ±[ms]. */
private fun seekBy(cmd: MediaCommand, ms: Long) {
log("gesture", "жест: свайп → $cmd ${ms}ms")
controller.applyMediaCommand(MediaCommandMsg(cmd, ms)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/**
* Тройной клик (кино): *слое-зависимые* эффекты кино — пауза + синх позиций на телефон — и
* штатно открыть под-слой ассистента ([AssistantLayout]) через [ctx]. Свой стейт сессии кадр
* чистит в onEnter ([AssistantEngine.beginSession]); движок из слоя кино не вызывается.
*/
private fun callAssistant() {
log("gesture", "жест: TripleClick → ассистент (стек: фильм → ассистент)")
val app = GlassesApp.instance
if (controller.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
app.hostConnection.sendPlaybackPositionNow()
ctx.open { c ->
AssistantLayout(c, app.assistantEngine, app.stt)
}
}
}
@@ -0,0 +1,168 @@
package pw.binom.viewmate.glasses
import androidx.compose.runtime.Composable
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* [LayoutManager] — ЛIFO-стек layout-ов очков: корень (двойным кликом не вытолкнуть) → под-слои.
* Проверяем: open/close/closeToRoot, вершина, композитинг (затемнение между слоями), индикатор
* записи, маршрутизацию жеста в верхний слой (и только в него).
*/
class LayoutManagerTest {
/** Тестируемый layout: пустые @Composable тела (в юнит-тестах не компонируются), лог жеста. */
private class FakeLayout(
val id: String,
private val onGesture: (Gesture) -> Unit = {},
) : Layout {
val gestures = mutableListOf<Gesture>()
@Composable
override fun drawStage() {}
@Composable
override fun drawStatusPanel() {}
override fun gesture(g: Gesture) {
gestures += g
onGesture(g)
}
}
private val root = FakeLayout("film")
private val assistant = FakeLayout("assistant")
@Test
fun `root is the initial current`() {
val m = LayoutManager { _ -> root }
assertEquals(1, m.depth)
assertIs<FakeLayout>(m.current)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `open pushes a child on top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
assertEquals(2, m.depth)
assertEquals("assistant", (m.current as FakeLayout).id)
assertEquals(listOf("film", "assistant"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `close pops to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `close on root is a no-op`() {
val m = LayoutManager { _ -> root }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `closeToRoot drops every non-root layer`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
assertEquals(3, m.depth)
m.closeToRoot()
assertEquals(1, m.depth)
assertEquals(listOf("film"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `compositing dims every layer but the top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
val layers = m.compositing()
assertEquals(3, layers.size)
// film → dim, assistant → dim, listening → без затемнения.
assertEquals(listOf("film", "assistant", "listening"), layers.map { (it.layout as FakeLayout).id })
assertEquals(listOf(true, true, false), layers.map { it.dim })
}
@Test
fun `single layer composites without dim`() {
val m = LayoutManager { _ -> root }
val layers = m.compositing()
assertEquals(listOf(false), layers.map { it.dim })
}
@Test
fun `dispatch routes only to the top layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.dispatch(Gesture.Click)
// Вершина (assistant) получила жест, корень — нет.
assertEquals(listOf(Gesture.Click), assistant.gestures)
assertEquals(0, root.gestures.size)
}
@Test
fun `dispatch after close routes back to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
m.dispatch(Gesture.DoubleClick)
assertEquals(0, assistant.gestures.size)
assertEquals(listOf(Gesture.DoubleClick), root.gestures)
}
@Test
fun `recording indicator reflects an active layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
assertFalse(m.recordingIndicator())
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.setRecordingIndicator(false)
assertFalse(m.recordingIndicator())
}
@Test
fun `popping a recording layer turns the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.close()
// assistant (POP) выключил индикатор — корень его не включал.
assertFalse(m.recordingIndicator())
}
@Test
fun `a re-opened layer starts with the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
m.close()
m.open { FakeLayout("assistant2") }
// Новый экземпляр не наследует включение — индикатор выключен, пока сам не включит.
assertFalse(m.recordingIndicator())
}
@Test
fun `child receives the shared context`() {
var received: LayoutContext? = null
val m = LayoutManager { _ -> root }
m.open { ctx -> received = ctx; assistant }
assertSame(m, received)
assertTrue(m.recordingIndicator() == false)
}
private fun assertSame(a: Any?, b: Any?) {
if (a !== b) throw AssertionError("ожидал тот же экземпляр контекста")
}
}
@@ -15,36 +15,46 @@ import kotlin.test.assertFalse
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.glasses.stt.SttModelStatus
/** /**
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино, * Карта жеста (docs/gestures-and-modes.md) — ЛIFO-стек кадров:
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»). * [NavFrame.Film] (корень) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон).
* Двойной клик POP-ает верхний кадр; корень не вытолкнуть. Плюс свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея.
*/ */
class AssistantGesturesTest { class AssistantGesturesTest {
private val movie = AssistantState() // Стек-состояния (вершина — текущий кадр). Корень всегда [NavFrame.Film].
private val listening = AssistantState(listening = true) private val movie = AssistantState() // [Film]
private val thinking = AssistantState(thinking = true) private val idle = AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant))
private val answer = AssistantState(assistantText = "ответ") private val listening =
AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening))
private val thinking = idle.copy(thinking = true)
private val answer = idle.copy(assistantText = "ответ")
private val blocked = listening.copy(sttStartBlocked = true)
// ————— режим по состоянию ————— // ————— режим по вершине стека —————
@Test @Test
fun movieStateIsNone() { fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie)) assertEquals(AssistantMode.NONE, assistantMode(movie))
} }
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */ /** Вершина Listening всё побеждает — жесты идут по матрице «слушаю». */
@Test @Test
fun listeningWinsOverEverything() { fun listeningTopWins() {
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а") val s = listening.copy(thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s)) assertEquals(AssistantMode.LISTENING, assistantMode(s))
} }
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */ /** «Ответ виден»: вершина Assistant, assistantText непуст — даже при thinking. */
@Test @Test
fun answerWinsOverThinking() { fun answerWinsOverThinking() {
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а"))) assertEquals(
AssistantMode.ANSWER,
assistantMode(idle.copy(thinking = true, assistantText = "а")),
)
} }
@Test @Test
@@ -52,7 +62,12 @@ class AssistantGesturesTest {
assertEquals(AssistantMode.THINKING, assistantMode(thinking)) assertEquals(AssistantMode.THINKING, assistantMode(thinking))
} }
// ————— кино ( NONE) ————— @Test
fun idleWhenAssistantTopNoContent() {
assertEquals(AssistantMode.IDLE, assistantMode(idle))
}
// ————— кино (вершина Film) —————
@Test @Test
fun movieMatrix() { fun movieMatrix() {
@@ -77,12 +92,12 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d) assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
} }
// ————— слушаю ————— // ————— слушаю (вершина Listening) —————
@Test @Test
fun listeningMatrix() { fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening)) assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening)) assertEquals(GestureDecision.StopListening, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
@@ -90,7 +105,16 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
} }
// ————— «думаю» (фраза отправлена, ответа ещё нет) ————— /** Модель не скачалась: кадр Listening на месте, микрофон закрыт — клик повторить, двойной выход. */
@Test
fun listeningBlockedMatrix() {
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), blocked))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), blocked))
}
// ————— «думаю» / «ответ» (вершина Assistant) —————
@Test @Test
fun thinkingMatrix() { fun thinkingMatrix() {
@@ -101,27 +125,87 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking)) assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
} }
// ————— «ответ виден» —————
@Test @Test
fun answerMatrix() { fun answerMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer)) assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer)) // Скролла ответа больше нет (решение пользователя: в диалоге ассистента не скроллим) —
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer)) // все свайпы (дискретные и плавный шлейф) игнорируются.
// Плавный: накопленная дельта шлейфа, знак — как пришёл. assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), answer))
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
} }
// ————— стек-переходы —————
/** Тройной клик в кино: кладём «ассистент» и сразу «слушаю» — [Film, Assistant, Listening]. */
@Test @Test
fun answerPageDeltaIsParameter() { fun enterAssistantPushesBothFrames() {
assertEquals( val s = movie.enterAssistant()
GestureDecision.ScrollAnswer(80f), assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), s.stack)
gestureDecision(slideForward(), answer, pageDeltaPx = 80f), assertEquals(AssistantMode.LISTENING, assistantMode(s))
) }
/** Click в idle/«думаю»/«ответ»: кладём «слушаю» поверх кадра ассистента. */
@Test
fun beginListeningFramePushesListening() {
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), idle.beginListeningFrame().stack)
assertEquals(AssistantMode.LISTENING, assistantMode(answer.beginListeningFrame()))
}
/** DoubleClick в «слушаю» — стоп: POP кадра, остаёмся в ассистенте (idle), чат не прерван. */
@Test
fun stopListeningStaysInAssistant() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.StopListening, decision)
// POP кадра «слушаю» — кадр ассистента (idle), оверлей остаётся, выхода нет.
val after = listening.stopListeningFrame(noSpeech = false)
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant), after.stack)
assertEquals(AssistantMode.IDLE, assistantMode(after))
assertTrue(overlayVisible(after))
assertFalse(after.listening)
// Idle: Click/Triple — возобновить запись; DoubleClick — POP ассистента (выход к фильму).
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), after))
assertEquals(GestureDecision.BeginListening, gestureDecision(TripleClick(), after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
}
/** Стоп первой записи (пустая история): оверлей НЕ закрывается — остаёмся в idle-кадре. */
@Test
fun stopFirstRecordingKeepsOverlay() {
val stopped = listening.copy(sttNoSpeech = true).stopListeningFrame(noSpeech = true)
assertTrue(stopped.assistantText.isBlank())
assertFalse(stopped.listening)
// Оверлей виден (idle-кадр), хотя ни фразы, ни ответа нет.
assertTrue(overlayVisible(stopped))
// И только явный выход (POP до фильма) прячет оверлей.
val exited = stopped.exitToFilm()
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** Стоп сбрасывает накопленный текст текущей (останавливаемой) записи. */
@Test
fun stopListeningClearsCancelledText() {
val after = listening.copy(sttText = "слова до отмены").stopListeningFrame(noSpeech = false)
assertEquals("", after.sttText)
assertFalse(after.listening)
}
/** Выход: POP всё до корня (фильм), сброс контента. */
@Test
fun exitToFilmPopsToRoot() {
val exited =
listening.copy(sttText = "ф", thinking = true, assistantText = "а", sttNoSpeech = true)
.exitToFilm()
assertEquals(listOf(NavFrame.Film), exited.stack)
assertFalse(overlayVisible(exited))
assertTrue(exited.assistantText.isBlank())
assertFalse(exited.thinking)
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
} }
// ————— сессия: кино → слушаю → «думаю» → ответ → выход ————— // ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@@ -130,9 +214,9 @@ class AssistantGesturesTest {
fun sessionTransitions() { fun sessionTransitions() {
var s = movie var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s)) assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.copy(listening = true) s = s.enterAssistant()
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s)) assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет s = s.stopListeningFrame(noSpeech = false).copy(thinking = true) // фраза отправлена, ответ ещё нет
assertEquals(AssistantMode.THINKING, assistantMode(s)) assertEquals(AssistantMode.THINKING, assistantMode(s))
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s)) assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
@@ -140,44 +224,42 @@ class AssistantGesturesTest {
assertEquals(AssistantMode.ANSWER, assistantMode(s)) assertEquals(AssistantMode.ANSWER, assistantMode(s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
// Выход: оверлей невидим, кино снова управляется кликом. // Выход: оверлей невидим, кино снова управляется кликом.
val exited = s.copy(assistantText = "") val exited = s.exitToFilm()
assertFalse(overlayVisible(exited)) assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited)) assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
} }
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */ /** Корень «фильм»: двойным кликом попу нельзя; вход в ассистент — Triple. */
@Test @Test
fun cancelStaysInConversation() { fun filmRootCannotBePopped() {
val decision = gestureDecision(DoubleClick(), listening) assertEquals(AssistantMode.NONE, assistantMode(movie))
assertEquals(GestureDecision.CancelDictation, decision) assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick. assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
} }
// ————— оверлей ————— // ————— оверлей —————
@Test @Test
fun overlayInvisibleInMovie() { fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает). // Настоящее кино-состояние: стек [Film], оверлей невидим.
assertFalse(overlayVisible(movie)) assertFalse(overlayVisible(movie))
} }
@Test @Test
fun overlayVisibleInListeningThinkingAndAnswer() { fun overlayVisibleInAssistantFrames() {
assertTrue(overlayVisible(listening)) assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking)) assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer)) assertTrue(overlayVisible(answer))
assertTrue(overlayVisible(idle))
} }
@Test @Test
fun thinkingHeaderIsDotDotDot() { fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking)) assertEquals("думаю...", overlayHeader(thinking))
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...». // После отправки фразы: вершина ещё Listening + thinking — тоже «думаю...».
assertEquals( assertEquals(
"думаю...", "думаю...",
overlayHeader(AssistantState(listening = true, thinking = true)), overlayHeader(listening.copy(thinking = true)),
) )
} }
@@ -188,10 +270,64 @@ class AssistantGesturesTest {
} }
@Test @Test
fun answerHeaderIsLastPhraseOrNull() { fun idleHeaderHintsGestures() {
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет"))) assertEquals("клик — говорить · двойной — выход", overlayHeader(idle))
assertNull(overlayHeader(answer)) // В ответе подсказка та же — чат прокручивается, жесты те же.
assertNull(overlayHeader(movie)) assertEquals("клик — говорить · двойной — выход", overlayHeader(answer))
}
/** Пустой клик без голоса — idle-кадр с «не расслышал». */
@Test
fun noSpeechIdleShowsHint() {
val noSpeech = idle.copy(sttNoSpeech = true)
assertEquals(AssistantMode.IDLE, assistantMode(noSpeech))
assertEquals("не расслышал — клик, чтобы повторить", overlayHeader(noSpeech))
assertTrue(overlayVisible(noSpeech))
}
// ————— старт отклонён: модель не скачалась —————
@Test
fun overlayVisibleWhenStartBlocked() {
assertTrue(overlayVisible(blocked))
assertFalse(overlayVisible(movie))
}
@Test
fun startBlockedHeaderIsModelNotDownloaded() {
// Блок бьёт и обычный «думаю...» — заголовок всегда «модель не скачалась».
assertEquals("модель не скачалась", overlayHeader(blocked))
assertEquals(
"модель не скачалась",
overlayHeader(blocked.copy(assistantText = "ответ")),
)
}
// ————— строка статуса модели —————
@Test
fun modelStatusLineReadyIsNull() {
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
}
@Test
fun modelStatusLineDownloadingShowsPercent() {
assertEquals(
"модель качается: 45%",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
)
}
@Test
fun modelStatusLineMissingAndFailed() {
assertEquals(
"модель не скачана — распознавание недоступно",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
)
assertEquals(
"модель не скачалась — повторю автоматически",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
)
} }
// ————— скролл ответа ————— // ————— скролл ответа —————
@@ -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))
}
}
+48 -3
View File
@@ -1,4 +1,5 @@
import java.util.Properties import java.util.Properties
import java.io.File
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@@ -7,6 +8,20 @@ plugins {
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
} }
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
val binomCertsDir: File = File(
(project.findProperty("binom.keystore.dir") as? String)
?: System.getenv("BINOM_KEYSTORE_DIR")
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
)
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
val binomCreds: Map<String, String> =
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
else emptyMap() })
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан. // LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную. // providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
val llmApiKey: String = run { val llmApiKey: String = run {
@@ -47,9 +62,30 @@ android {
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"") buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
} }
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
// Без кестора binomCreds пусто → release остаётся неподписанным.
if (binomCreds.isNotEmpty()) {
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
signingConfigs {
create("binom") {
storeFile = binomStoreFile
storePassword = binomStorePass
keyAlias = binomAlias
keyPassword = binomKeyPass
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
} }
} }
@@ -97,16 +133,25 @@ dependencies {
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона // RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT") implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
// QR-код для паринга очков (pairCode) — рендер BitMatrix->Bitmap
implementation("com.google.zxing:core:3.5.2")
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый) // Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
implementation(libs.koog.openai.client) implementation(libs.koog.openai.client)
implementation(libs.koog.http.client.okhttp) implementation(libs.koog.http.client.okhttp)
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU) // LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU):
implementation(libs.litertlm.android) // 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") implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
// Офлайн-распознавание Qwen3-ASR (asr-kmp, caffeine nexus) — Qwen3AsrStt
implementation("pw.binom.asr:asr-qwen3-android:4")
// WS-сервер телефона (CIO — работает на Android) // WS-сервер телефона (CIO — работает на Android)
implementation("io.ktor:ktor-server-core:3.3.0") implementation("io.ktor:ktor-server-core:3.3.0")
implementation("io.ktor:ktor-server-cio:3.3.0") implementation("io.ktor:ktor-server-cio:3.3.0")
+1
View File
@@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -20,34 +22,66 @@ import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File import java.io.File
import kotlin.math.abs import kotlin.math.abs
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */ /** Решение одного sync-цикла: speed — желаемая скорость аудио; seekToMs не null → редкий жёсткий seek; playing не null → play/pause. */
internal data class SyncDecision( internal data class SyncDecision(
val speed: Float = 1f,
val seekToMs: Long? = null, val seekToMs: Long? = null,
val playing: Boolean? = null, val playing: Boolean? = null,
) )
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */ /** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
internal const val SYNC_THRESHOLD_MS = 1000L internal const val TRIGGER_BAND_MS = 150L
/**
* Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f).
* 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED].
*/
internal const val DRIFT_GAIN = 0.0002f
/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */
internal const val MAX_DRIFT_SPEED = 0.10f
/**
* Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo.
* Поднят с 2000 до 5000: обычная буферная пауза вывода (0.5–1.5 с) при локальном файле не должна
* форсировать жёсткий seek (он перебуферизирует ExoPlayer и даёт слышимую тишину + прыжок позиции);
* умеренный рассинхрон закрывается плавной коррекцией скоростью, бесшовно.
*/
internal const val HARD_SEEK_THRESHOLD_MS = 5000L
/** EWMA-сглаживание измеренной латентности линка (доля старого значения). */
internal const val LINK_LATENCY_ALPHA = 0.8
/** Верхний предел измеренной латентности: выше — артефакт (обрыв/зависание), игнорируем. */
internal const val LINK_LATENCY_MAX_MS = 2000.0
/** /**
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android): * Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |audio - video| > [thresholdMs] → перемотать на позицию очков; * - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
* - isPlaying очков отличается от состояния аудио → play/pause. * - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем),
* звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается
* [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно;
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
*/ */
internal fun decideSync( internal fun decideSync(
audioPosition: Long, audioPosition: Long,
videoPosition: Long, videoPosition: Long,
audioPlaying: Boolean, audioPlaying: Boolean,
videoPlaying: Boolean, videoPlaying: Boolean,
thresholdMs: Long = SYNC_THRESHOLD_MS,
): SyncDecision { ): SyncDecision {
val seekToMs = if (abs(audioPosition - videoPosition) > thresholdMs) videoPosition else null val diff = audioPosition - videoPosition
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
val speed = when {
seekToMs != null -> 1f
abs(diff) <= TRIGGER_BAND_MS -> 1f
else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED)
}
val playing = when { val playing = when {
videoPlaying == audioPlaying -> null videoPlaying == audioPlaying -> null
videoPlaying -> true videoPlaying -> true
else -> false else -> false
} }
return SyncDecision(seekToMs = seekToMs, playing = playing) return SyncDecision(speed = speed, seekToMs = seekToMs, playing = playing)
} }
/** /**
@@ -115,15 +149,51 @@ class AudioSyncPlayer(private val context: Context) {
private var player: ExoPlayer? = null private var player: ExoPlayer? = null
private var ticker: Job? = null private var ticker: Job? = null
/** Позиция очков из последнего sync (для догонки после неточного seekTo). */ /** Слушатель буферизации ExoPlayer: логирует паузы вывода с длительностью (причина «немого» звука:
* длительная буферизация = локальная/S3-дорога, короткая/абсолютное отсутствие = BT-стек). */
private val bufferingListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> {
bufferingStartMs = System.currentTimeMillis()
log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)")
}
Player.STATE_READY -> {
val bufferedMs = if (bufferingStartMs > 0L) System.currentTimeMillis() - bufferingStartMs else 0L
bufferingStartMs = 0L
log("audio", "буфер готов, вывод возобновлён${if (bufferedMs > 0L) " (буферизация ${bufferedMs} мс)" else ""}")
}
}
}
}
/** Позиция очков из последнего sync. */
private var lastVideoPositionMs = 0L private var lastVideoPositionMs = 0L
/** IsPlaying очков из последнего sync. */ /** IsPlaying очков из последнего sync. */
private var lastVideoPlaying = false private var lastVideoPlaying = false
/** Сглаженная (EWMA) латентность линка очки→телефон (мс): запаздывание мастер-позиции. */
private var linkLatencyMs = 0.0
private var lastLatencyLoggedAt = 0L
/** Текущий источник для логов: «стриминг S3» или «локальный файл» — виден в каждой периодической
* строке «audio sync:», чтобы любой фрагмент logcat отвечал на вопрос «стримлю или скачанное?». */
private var sourceLabel = "нет"
/** Момент входа в STATE_BUFFERING (для лога длительности; 0 — не в буферизации). */
private var bufferingStartMs = 0L
private val _positionMs = MutableStateFlow(0L) private val _positionMs = MutableStateFlow(0L)
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */ /** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
val positionMs: StateFlow<Long> = _positionMs.asStateFlow() val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
private val _diffMs = MutableStateFlow(0L)
/** |звук − видео| из последнего sync-цикла (мс), для индикатора статус-панели очков. 0 — синхронизации не было. */
val diffMs: StateFlow<Long> = _diffMs.asStateFlow()
/** Играет ли видео на очках (по последнему PlaybackPosition). Только с главного потока. */
val videoPlaying: Boolean
get() = lastVideoPlaying
/** Есть ли активный аудио-плеер (просмотр на очках). */ /** Есть ли активный аудио-плеер (просмотр на очках). */
val isActive: Boolean val isActive: Boolean
get() = player != null get() = player != null
@@ -139,43 +209,62 @@ class AudioSyncPlayer(private val context: Context) {
} else { } else {
Uri.fromFile(File(urlOrFile)) Uri.fromFile(File(urlOrFile))
} }
val p = player ?: ExoPlayer.Builder(context).build().also { player = it } val p = player ?: ExoPlayer.Builder(context).build().also {
it.addListener(bufferingListener)
player = it
}
p.setMediaItem(MediaItem.fromUri(uri)) p.setMediaItem(MediaItem.fromUri(uri))
p.prepare() p.prepare()
p.seekTo(startPositionMs.coerceAtLeast(0L)) p.seekTo(startPositionMs.coerceAtLeast(0L))
p.playWhenReady = true p.playWhenReady = true
log("audio", "играю $urlOrFile с ${startPositionMs} мс") val isStream = urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://")
sourceLabel = if (isStream) "стриминг S3" else "локальный файл"
log("audio", "играю $sourceLabel: $urlOrFile с ${startPositionMs} мс")
startTicker() startTicker()
} }
} }
/** /**
* Коррекция по позиции очков: diff > 1000 мс → seek, play/pause по isPlaying. * Коррекция по позиции очков: за пределами гистерезис-зоны скорость плавно
* Пишет ключевой лог для QA при каждом вызове. * подгоняется через setPlaybackParameters (без seekTo); жёсткий seek — только
* при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f.
* Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове.
*/ */
fun sync(positionMs: Long, isPlaying: Boolean) { fun sync(positionMs: Long, isPlaying: Boolean, sentTs: Long = 0L) {
main.post { main.post {
val p = player ?: return@post val p = player ?: return@post
lastVideoPositionMs = positionMs lastVideoPositionMs = positionMs
lastVideoPlaying = isPlaying lastVideoPlaying = isPlaying
val audioPos = p.currentPosition // Латентность линка очки→телефон: сколько мс прошло от отправки позиции на очках до обработки здесь.
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying) // Часы синхронизированы через mercury.syncTime(), так что это чистая транспортная задержка.
decision.seekToMs?.let { if (sentTs > 0L) {
p.seekTo(it) val measured = (System.currentTimeMillis() - sentTs).toDouble()
lastVideoPositionMs = it if (measured in 0.0..LINK_LATENCY_MAX_MS) {
// Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с), linkLatencyMs = linkLatencyMs * LINK_LATENCY_ALPHA + measured * (1 - LINK_LATENCY_ALPHA)
// второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с). val now = System.currentTimeMillis()
main.postDelayed({ if (now - lastLatencyLoggedAt > 5000) {
val pp = player ?: return@postDelayed lastLatencyLoggedAt = now
val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду log("audio sync:", "латентность линка: ${linkLatencyMs.toInt()} мс")
if (abs(pp.currentPosition - target) > 500) {
pp.seekTo(target)
log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})")
} }
}, 1_000) }
}
// Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел).
val videoPos = positionMs + linkLatencyMs.toLong()
val audioPos = p.currentPosition
_diffMs.value = abs(audioPos - videoPos)
val decision = decideSync(audioPos, videoPos, p.isPlaying, isPlaying)
when {
decision.seekToMs != null -> {
p.seekTo(decision.seekToMs)
p.setPlaybackParameters(PlaybackParameters(1f))
log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)} src=$sourceLabel)")
}
else -> {
p.setPlaybackParameters(PlaybackParameters(decision.speed))
log("audio sync:", "video=$videoPos audio=$audioPos diff=${abs(audioPos - videoPos)} speed=${decision.speed} src=$sourceLabel")
}
} }
decision.playing?.let { if (it) p.play() else p.pause() } decision.playing?.let { if (it) p.play() else p.pause() }
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
} }
} }
@@ -198,7 +287,10 @@ class AudioSyncPlayer(private val context: Context) {
ticker = null ticker = null
val p = player val p = player
player = null player = null
lastVideoPlaying = false
sourceLabel = "нет"
_positionMs.value = 0L _positionMs.value = 0L
_diffMs.value = 0L
if (p != null) { if (p != null) {
p.playWhenReady = false p.playWhenReady = false
p.stop() p.stop()
@@ -44,12 +44,17 @@ class BtServerTransport(
/** /**
* Runtime-пермишены Bluetooth, зависящие от версии платформы * Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12): * (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 (опасные, запрашиваются в рантайме). * ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/ */
fun runtimePermissions(): Array<String> = 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) arrayOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE,
)
} else { } else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN) arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
} }
@@ -1,6 +1,5 @@
package pw.binom.viewmate.phone 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.LlmModelChoice
import pw.binom.viewmate.phone.agent.LocalLlmClient import pw.binom.viewmate.phone.agent.LocalLlmClient
import java.io.BufferedReader import java.io.BufferedReader
@@ -122,9 +121,62 @@ object DebugHttpServer {
// --- Mercury BLE (полевой тест либы rayneo-mercury) --- // --- Mercury BLE (полевой тест либы rayneo-mercury) ---
method == "GET" && path.startsWith("/mercury/status") -> { method == "GET" && path.startsWith("/mercury/status") -> {
val s = MercuryBridge.state.value 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"}}""" 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 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") -> { method == "POST" && path.startsWith("/mercury/connect") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() } val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}""" response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
@@ -151,6 +203,18 @@ object DebugHttpServer {
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}""" response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200 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 -> { else -> {
response = """{"error":"нет такого пути: $method $path"}""" response = """{"error":"нет такого пути: $method $path"}"""
code = 404 code = 404
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.log.LogEntry
import pw.binom.viewmate.core.net.GlassesServerTransport import pw.binom.viewmate.core.net.GlassesServerTransport
import pw.binom.viewmate.core.phone.GlassesSender import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.phone.PhoneState
@@ -22,10 +23,12 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Gesture import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.LogBatchMsg
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SttAudio import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttText
import pw.binom.viewmate.core.protocol.StopStt import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson import pw.binom.viewmate.core.protocol.protocolJson
@@ -95,7 +98,14 @@ class GlassesHub(
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после * с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
* рестарта телефона (см. PhoneApp.autostartAudio). * рестарта телефона (см. PhoneApp.autostartAudio).
*/ */
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long, durationMs: Long) -> Unit)? = null
/**
* Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
* (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
*/
@Volatile
var onLogBatch: ((List<LogEntry>) -> Unit)? = null
/** /**
* Стриминговое распознавание речи с очков. Лениво создаётся через * Стриминговое распознавание речи с очков. Лениво создаётся через
@@ -186,13 +196,18 @@ class GlassesHub(
this.state.playing = msg.playing this.state.playing = msg.playing
val state = if (msg.playing) "играет" else "пауза" val state = if (msg.playing) "играет" else "пауза"
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}") log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}")
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex) onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs, msg.durationMs)
} }
is GlassesOff -> { is GlassesOff -> {
log("glasses", "очки выключились → пауза (${msg.reason})") log("glasses", "очки выключились → пауза (${msg.reason})")
this.state.playing = false this.state.playing = false
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L, -1L)
}
is LogBatchMsg -> {
log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
onLogBatch?.invoke(msg.entries)
} }
is SttAudio -> { is SttAudio -> {
@@ -221,6 +236,19 @@ class GlassesHub(
} }
} }
} }
// Новый путь: очки распознали речь локально и прислали готовый текст —
// в 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 package pw.binom.viewmate.phone
import android.util.Log import android.util.Log
import pw.binom.viewmate.core.log.BatchingLogCollector
/** /**
* Единый лог телефона. * Единый лог телефона.
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах * На устройстве — 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) { fun log(tag: String, message: String) {
val full = "[view-mate] $tag $message" val full = "[view-mate] $tag $message"
runCatching { runCatching {
@@ -15,4 +27,5 @@ fun log(tag: String, message: String) {
}.onFailure { }.onFailure {
println(full) println(full)
} }
logCollector?.record(tag, message)
} }
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.view.Gravity import android.view.Gravity
import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestBtPermissions() requestBtPermissions()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent { setContent {
ViewMateApp() ViewMateApp()
} }
@@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import pw.binom.mercury.DiscoveredGlasses import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury import pw.binom.mercury.RayNeoMercury
import pw.binom.mercury.model.MyWifiInfo
import pw.binom.mercury.pair.PairManager
/** /**
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE). * Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
@@ -30,6 +32,9 @@ object MercuryBridge {
private const val TAG = "MercuryBridge" private const val TAG = "MercuryBridge"
/** Пакет нашего очкового приложения (applicationId из app-glasses). */
const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses"
private var mercury: RayNeoMercury? = null private var mercury: RayNeoMercury? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -38,6 +43,15 @@ object MercuryBridge {
val glassesName: String? = null, val glassesName: String? = null,
val statusText: String = "не подключено", val statusText: String = "не подключено",
val lastError: String? = null, 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()) private val _state = MutableStateFlow(UiState())
@@ -53,8 +67,15 @@ object MercuryBridge {
mercury!!.events.collect { event -> mercury!!.events.collect { event ->
when (event) { when (event) {
is RayNeoMercury.Event.Connected -> { is RayNeoMercury.Event.Connected -> {
_state.value = _state.value.copy(connected = true) _state.value = _state.value.copy(connected = true, pairCode = null)
setStatus("подключено (BLE)") setStatus("подключено (BLE)")
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
// ползунок яркости и переключатель WiFi показывали реальные значения.
scope.launch { mercury?.pullGeneralStatus() }
// Синхронизируем часы очков с телефоном: до NTP очки стартуют с
// нулевой меткой (~1970), и логи получают «старые» таймстемпы,
// из-за чего Loki отвергает весь batch (HTTP 400) и спул клинит.
scope.launch { mercury?.syncTime() }
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе // Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
// при гашении экрана очки рвут BLE-связь через ~3 секунды. // при гашении экрана очки рвут BLE-связь через ~3 секунды.
// Повторяем, пока очки не будут готовы (они переподключаются // Повторяем, пока очки не будут готовы (они переподключаются
@@ -94,8 +115,42 @@ object MercuryBridge {
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%") is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}") is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line) is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}") is RayNeoMercury.Event.GeneralStatusUpdate -> {
is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}") 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}")
}
}
}
}
} }
} }
} }
@@ -109,7 +164,8 @@ object MercuryBridge {
val m = mercury ?: return false val m = mercury ?: return false
return m.hasPermissions() && return m.hasPermissions() &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && 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_CONNECT) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED
} }
/** /**
@@ -141,6 +197,20 @@ object MercuryBridge {
setStatus("подключено (клиент)") setStatus("подключено (клиент)")
return true 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} с") setError("очки не подключились за ${timeoutMillis / 1000} с")
@@ -167,6 +237,16 @@ object MercuryBridge {
return ok return ok
} }
/**
* Запустить приложение на очках по пакету (протокол «app-market», [RayNeoMercury.appMarketLaunch]).
* Очки сами открывают приложение по [pkg]; [extras] — опциональные аргументы (как Intent).
*/
suspend fun launchApp(pkg: String, extras: Map<String, String>? = null): Boolean {
val ok = mercury?.appMarketLaunch(pkg, extras) ?: false
setStatus(if (ok) "запуск приложения $pkg на очках" else "ошибка отправки (нет связи?)")
return ok
}
/** Автоблокировка экрана через N секунд; -1 = никогда. */ /** Автоблокировка экрана через N секунд; -1 = никогда. */
suspend fun autoLockTime(time: Int): Boolean { suspend fun autoLockTime(time: Int): Boolean {
val ok = mercury?.setAutoLockTime(time) ?: false val ok = mercury?.setAutoLockTime(time) ?: false
@@ -174,13 +254,158 @@ object MercuryBridge {
return ok 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) { private fun setStatus(text: String) {
log("status", text) log("status", text)
_state.value = _state.value.copy(statusText = text, lastError = null) _state.value = _state.value.copy(statusText = text, lastError = null)
} }
private fun setError(text: String) { private fun setError(text: String) {
Log.e(TAG, text) log("mercury", text)
_state.value = _state.value.copy(statusText = text, lastError = text) _state.value = _state.value.copy(statusText = text, lastError = text)
} }
@@ -193,6 +418,6 @@ object MercuryBridge {
} }
private fun log(tag: String, msg: String) { private fun log(tag: String, msg: String) {
Log.d(TAG, "[$tag] $msg") 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,12 +17,18 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.log.BatchingLogCollector
import pw.binom.viewmate.core.log.FileLogSpool
import pw.binom.viewmate.core.log.LokiConfig
import pw.binom.viewmate.core.log.SystemClock
import pw.binom.viewmate.core.log.lokiLogSink
import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.AssistantState import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.AudioSyncInfo
import pw.binom.viewmate.core.protocol.ChatHistoryItem import pw.binom.viewmate.core.protocol.ChatHistoryItem
import pw.binom.viewmate.core.protocol.ChatHistoryMsg import pw.binom.viewmate.core.protocol.ChatHistoryMsg
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
@@ -38,6 +44,7 @@ import pw.binom.viewmate.phone.agent.GemmaModelDownloader
import pw.binom.viewmate.phone.agent.JellyfinMediaSource import pw.binom.viewmate.phone.agent.JellyfinMediaSource
import pw.binom.viewmate.phone.agent.LocalLlmClient import pw.binom.viewmate.phone.agent.LocalLlmClient
import pw.binom.viewmate.phone.agent.LocalLlmModelState 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.LlmModelChoice
import pw.binom.viewmate.phone.agent.LlmPrefs import pw.binom.viewmate.phone.agent.LlmPrefs
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
@@ -46,8 +53,9 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient
import pw.binom.viewmate.phone.agent.SkillRegistry import pw.binom.viewmate.phone.agent.SkillRegistry
import pw.binom.viewmate.phone.agent.SkillRepository import pw.binom.viewmate.phone.agent.SkillRepository
import pw.binom.viewmate.phone.agent.ToolsetRegistry import pw.binom.viewmate.phone.agent.ToolsetRegistry
import pw.binom.viewmate.phone.stt.PhraseRecognizer
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
import pw.binom.viewmate.phone.stt.SttStreamer import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
/** /**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState, * Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
@@ -82,6 +90,13 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */ /** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) } val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
/** Прогресс просмотра по элементам (filesDir/watchprogress.json): возобновление + индикация в каталоге. */
val watchProgress: WatchProgressStore by lazy { WatchProgressStore(filesDir) }
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
lateinit var logCollector: BatchingLogCollector
private set
/** История общения с ассистентом (SQLite, "assistant.db"). */ /** История общения с ассистентом (SQLite, "assistant.db"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) } val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
@@ -117,6 +132,28 @@ class PhoneApp : Application() {
/** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */ /** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */
fun localLlmClient(): LocalLlmClient? = localLlm 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]). */ /** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
// lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и // lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
// getSharedPreferences/getExternalFilesDir) доступен только после; ленивость — // getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
@@ -130,6 +167,9 @@ class PhoneApp : Application() {
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() } val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
private var localLlmDownloadJob: Job? = null private var localLlmDownloadJob: Job? = null
/** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */
private var localLlmAutoRetry = false
private fun initialLocalLlmState(): LocalLlmModelState = private fun initialLocalLlmState(): LocalLlmModelState =
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
@@ -149,30 +189,41 @@ class PhoneApp : Application() {
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)), * Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256. * прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
* Повторный вызов во время скачивания — no-op. * Повторный вызов во время скачивания — no-op.
* Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп»)
* ждёт 60с и снова пытается, пока файла нет.
*/ */
fun downloadLocalLlmModel() { fun downloadLocalLlmModel() {
val current = localLlmDownloadJob val current = localLlmDownloadJob
if (current != null && current.isActive) return if (current != null && current.isActive) return
localLlmAutoRetry = true
localLlmDownloadJob = scope.launch { localLlmDownloadJob = scope.launch {
val target = localModelFile() while (localLlmAutoRetry && !localModelFile().isFile) {
_localLlmState.value = LocalLlmModelState.Downloading(0) val target = localModelFile()
try { _localLlmState.value = LocalLlmModelState.Downloading(0)
GemmaModelDownloader(target).download { percent -> try {
_localLlmState.value = LocalLlmModelState.Downloading(percent) GemmaModelDownloader(target).download { percent ->
_localLlmState.value = LocalLlmModelState.Downloading(percent)
}
_localLlmState.value = LocalLlmModelState.Ready
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
return@launch
} catch (e: CancellationException) {
_localLlmState.value = LocalLlmModelState.Idle
throw e
} catch (e: Exception) {
val msg = e.message ?: e.toString()
_localLlmState.value = LocalLlmModelState.Error(msg)
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
delay(60_000)
} }
_localLlmState.value = LocalLlmModelState.Ready
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
log("llm", "не удалось скачать модель: ${e.message}")
} }
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
} }
} }
/** Остановить скачивание локальной модели (частичный файл удаляется). */ /** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
fun cancelLocalLlmDownload() { fun cancelLocalLlmDownload() {
localLlmAutoRetry = false
localLlmDownloadJob?.cancel() localLlmDownloadJob?.cancel()
} }
@@ -213,8 +264,11 @@ class PhoneApp : Application() {
).also { toolkit = it } ).also { toolkit = it }
return Assistant( return Assistant(
chatDao = chatDao, chatDao = chatDao,
chat = { history, system -> llm.chat(history, system) }, chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
toolkit = tk, toolkit = tk,
// Нативный тул-канал LiteRT: только локальная модель видит схемы
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
nativeTools = choice == LlmModelChoice.LOCAL,
// Локальной модели нужно время: инициализация движка ~17с на // Локальной модели нужно время: инициализация движка ~17с на
// CPU + генерация. Серверная укладывается в 30с, локальной // CPU + генерация. Серверная укладывается в 30с, локальной
// даём 5 минут (иначе withTimeoutOrNull режет генерацию). // даём 5 минут (иначе withTimeoutOrNull режет генерацию).
@@ -273,25 +327,35 @@ class PhoneApp : Application() {
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки. * Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем. * Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
*/ */
private val NO_LLM_ANSWER =
"Серверный LLM не настроен (нет API-ключа в local.properties). " +
"Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " +
"либо задайте llm.apiKey и пересоберите."
private suspend fun assistantLoop() { private suspend fun assistantLoop() {
for (phrase in assistantChannel) { for (phrase in assistantChannel) {
log("assistant", "получена фраза: $phrase") log("assistant", "получена фраза: $phrase")
val assistant = ensureAssistant() val assistant = ensureAssistant()
val answer: String
if (assistant == null) { if (assistant == null) {
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена") // Серверный LLM без API-ключа: не «пропущена в никуда», а явная
continue // подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
} log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING)) answer = NO_LLM_ANSWER
val answer = runCatching { persistNoLlm(phrase)
val active = _activeSessionId.value } else {
if (active != null) { server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
assistant.processInSession(active, phrase) answer = runCatching {
} else { val active = _activeSessionId.value
assistant.process(phrase) if (active != null) {
assistant.processInSession(active, phrase)
} else {
assistant.process(phrase)
}
} }
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
} }
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
server.hub.broadcast(ShowText(answer)) server.hub.broadcast(ShowText(answer))
// Очки показывают историю всей сессии (свайпами); ответ уже в ней. // Очки показывают историю всей сессии (свайпами); ответ уже в ней.
server.hub.broadcast(chatHistoryMsg()) server.hub.broadcast(chatHistoryMsg())
@@ -299,6 +363,16 @@ class PhoneApp : Application() {
} }
} }
/**
* Нет работающего ассистента (сервер без ключа): сохраняем и user-фразу, и
* короткую подсказку, чтобы в чате/на очках не было «провала» в истории.
*/
private fun persistNoLlm(phrase: String) {
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: chatDao.createSession("нет LLM")
chatDao.addMessage(id, role = ChatRole.USER, content = phrase)
chatDao.addMessage(id, role = ChatRole.ASSISTANT, content = NO_LLM_ANSWER)
}
/** /**
* История активной (или последней) сессии для очков: только роли user/assistant, * История активной (или последней) сессии для очков: только роли user/assistant,
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1). * tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
@@ -343,34 +417,38 @@ class PhoneApp : Application() {
} }
/** /**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models — * Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба * кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс. * на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
*/ */
private fun ensureStt(): SttStreamer? { private fun ensureStt(): SttStreamer? {
synchronized(sttLock) { synchronized(sttLock) {
sttStreamer?.let { return it } sttStreamer?.let { return it }
val modelsDir = File(filesDir, "models") val modelsDir = File(filesDir, "models")
val encoder = File(modelsDir, "small-encoder.int8.onnx") val qwenConv = File(modelsDir, "conv_frontend.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx") val qwenEnc = File(modelsDir, "encoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt") val qwenDec = File(modelsDir, "decoder.int8.onnx")
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) { val qwenTok = File(modelsDir, "tokenizer")
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null return null
} }
val whisper = WhisperStt( val stt: PhraseRecognizer = Qwen3AsrStt(
encoderPath = encoder.absolutePath, convFrontendPath = qwenConv.absolutePath,
decoderPath = decoder.absolutePath, encoderPath = qwenEnc.absolutePath,
tokensPath = tokens.absolutePath, decoderPath = qwenDec.absolutePath,
tokenizerDir = qwenTok.absolutePath,
numThreads = 4, numThreads = 4,
hotwords = STT_HOTWORDS,
) )
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) { val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw") File(filesDir, "stt_dump_phone.raw")
} else { } else {
null null
} }
log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)")
val s = SttStreamer( val s = SttStreamer(
stt = whisper, stt = stt,
onSilence30s = { onSilence30s = {
log("stt", "тишина 30 с — автo-отмена") log("stt", "тишина 30 с — автo-отмена")
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) } scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
@@ -385,7 +463,7 @@ class PhoneApp : Application() {
) )
sttStreamer = s sttStreamer = s
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}") log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов (Whisper-small int8)") log("stt", "SttStreamer готов (qwen3_asr)")
return s return s
} }
} }
@@ -428,21 +506,64 @@ class PhoneApp : Application() {
server.start() server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
DebugHttpServer.start(this) DebugHttpServer.start(this)
OpenAiHttpServer.start(this)
nsd.publish() nsd.publish()
server.hub.sttFactory = { ensureStt() } server.hub.sttFactory = { ensureStt() }
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } } server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
// Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki
// выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool.
logCollector = BatchingLogCollector(
device = "phone",
sink = lokiLogSink(),
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64),
clock = SystemClock(),
flushIntervalMs = 60_000,
maxBatchEntries = 500,
)
logCollector.start(scope)
attachLogCollector(logCollector)
pw.binom.viewmate.core.logCollector = logCollector
server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) }
log(
"server",
"лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " +
"спул: filesDir/logspool)",
)
scope.launch { assistantLoop() } scope.launch { assistantLoop() }
// Автодокачка локальной LLM-модели при старте, если файла нет.
// Каскадный автоповтор (60с) до успеха или «стоп» в настройках.
if (!localModelFile().isFile) {
log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})")
downloadLocalLlmModel()
}
val lastPositionAt = AtomicLong(0) val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false) val lastWasPlaying = AtomicBoolean(false)
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен) // + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex -> // + прогресс просмотра (resume + индикация в каталоге)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs, durationMs ->
lastPositionAt.set(System.currentTimeMillis()) lastPositionAt.set(System.currentTimeMillis())
lastWasPlaying.set(playing) lastWasPlaying.set(playing)
audioSync.sync(positionMs, playing) audioSync.sync(positionMs, playing, sentTs)
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) } scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
if (itemId != null) {
scope.launch(Dispatchers.IO) { watchProgress.upsert(itemId, positionMs, durationMs) }
}
}
// Индикатор аудио-синхронизации в статус-панели очков: |звук − видео| в мс
// на каждый sync-цикл (~5с); active=false (видео на очках не играет) — панель скрывает.
scope.launch {
audioSync.diffMs.collect { diff ->
server.hub.broadcast(
AudioSyncInfo(diffMs = diff.toInt(), active = audioSync.isActive && audioSync.videoPlaying),
)
}
} }
// watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту // watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту
@@ -512,5 +633,8 @@ class PhoneApp : Application() {
companion object { companion object {
lateinit var instance: PhoneApp lateinit var instance: PhoneApp
private set private set
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
} }
} }
@@ -12,6 +12,9 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080 const val SERVER_PORT = 8080
/** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */
const val OPENAI_SERVER_PORT = 8800
/** /**
* Выбор транспорта очки↔телефон (TASK-transport.md п.4): * Выбор транспорта очки↔телефон (TASK-transport.md п.4):
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений); * `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
@@ -19,8 +22,13 @@ object PhoneConfig {
*/ */
const val USE_BLUETOOTH = true 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. */ /** Период проверки watchdog. */
const val WATCHDOG_TICK_MS = 2_000L 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 note = pendingNotes.remove(sessionId)
val activeSets = toolsets.activeNames(sessionId) val activeSets = toolsets.activeNames(sessionId)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ") sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.") .append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ") if (nativeTools) {
.append("После получения результата тула ответь пользователю текстом.") sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
.append("После получения результата тула ответь пользователю текстом.")
} else {
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
.append("После получения результата тула ответь пользователю текстом.")
}
sb.append("\n\nЯдро:") sb.append("\n\nЯдро:")
CORE_LINES.forEach { sb.append('\n').append(it) } CORE_LINES.forEach { sb.append('\n').append(it) }
val catalog = toolsets.catalogLines() val catalog = toolsets.catalogLines()
@@ -85,6 +94,27 @@ class AgentToolkit(
return sb.toString() 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` (или «голое» значение для * Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`, * одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта. * (общая с RemoteLlmClient), метод оставлен для API/тестов.
*/ */
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> { fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
val s = raw?.trim() parseToolCallArgs(raw, expected)
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("[,&]")
// ---------- диспач вызовов ---------- // ---------- диспач вызовов ----------
@@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull
* (сериализация фраз — LLM-вызовы не пересекаются). * (сериализация фраз — LLM-вызовы не пересекаются).
* *
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если * Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной * передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально). * [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю, * Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
* затем делает повторный вызов LLM. Невидимые системные тулы * затем делает повторный вызов LLM. Невидимые системные тулы
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто * (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера) * пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
* возвращается пользователю как финальный ответ. * возвращается пользователю как финальный ответ.
*/ */
class Assistant( class Assistant(
private val chatDao: ChatDao, private val chatDao: ChatDao,
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String, private val chat: suspend (history: List<ChatMessage>, tools: List<ToolSpec>, system: String?) -> LlmResult,
private val toolkit: AgentToolkit? = null, private val toolkit: AgentToolkit? = null,
private val tools: List<AgentTool> = AgentTools.ALL, private val tools: List<AgentTool> = AgentTools.ALL,
private val nativeTools: Boolean = false,
private val timeoutMs: Long = 30_000, private val timeoutMs: Long = 30_000,
) { ) {
/** /**
@@ -50,7 +53,7 @@ class Assistant(
private suspend fun processInSession(sessionId: Long): String { private suspend fun processInSession(sessionId: Long): String {
val history = chatDao.messagesForSession(sessionId).takeLast(20) val history = chatDao.messagesForSession(sessionId).takeLast(20)
var answer = callLlm(history, sessionId) var result = callLlm(history, sessionId)
var lastToolResult: String? = null var lastToolResult: String? = null
var visibleCalls = 0 var visibleCalls = 0
var llmCalls = 1 var llmCalls = 1
@@ -58,31 +61,31 @@ class Assistant(
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают, // зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS]. // но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
while (true) { while (true) {
val parsed = parseToolCall(answer) val call = result.toolCalls.firstOrNull()
?: return answer // callLlm уже сохранил ASSISTANT-сообщение ?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
val toolName = parsed.name val toolName = call.name
if (toolkit == null) { if (toolkit == null) {
// Наследственный путь (без механики тулсетов): простые тулы без аргументов. // Наследственный путь (без механики тулсетов): простые тулы без аргументов.
val tool = tools.firstOrNull { it.name == toolName } val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) { if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
return "Тул $toolName не найден" return "Тул $toolName не найден"
} }
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" } lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName) chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
logTool(sessionId, toolName, lastToolResult) logTool(sessionId, toolName, lastToolResult)
if (++visibleCalls >= MAX_TOOL_CALLS) break if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++ llmCalls++
continue 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 -> { is AgentToolkit.Dispatch.Run -> {
lastToolResult = result.result lastToolResult = dispatch.result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName) chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
logTool(sessionId, toolName, result.result) logTool(sessionId, toolName, dispatch.result)
if (++visibleCalls >= MAX_TOOL_CALLS) break if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++ llmCalls++
} }
@@ -92,29 +95,34 @@ class Assistant(
// (переход в состояние 3 — «Активные тулсеты»). // (переход в состояние 3 — «Активные тулсеты»).
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)") logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
if (llmCalls++ >= MAX_LLM_CALLS) break 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 — отдаём последний результат тула как ответ, // LLM зациклилась на тул-вызовах — отдаём последний результат тула как
// чтобы пользователь получил данные, а не сырой маркер. // ответ, чтобы пользователь получил данные, а не сырой маркер.
val stillTool = parseToolCall(answer) val stillTool = result.toolCalls.firstOrNull()
return if (stillTool != null && lastToolResult != null) { return if (stillTool != null && lastToolResult != null) {
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult" "TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
} else { } else {
answer result.text
} }
} }
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String { private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt() val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) } val specs = if (toolkit != null) {
?: return "Ассистент не ответил (таймаут)" toolkit.toolSpecs(sessionId)
if (!isToolCall(answer)) { } else {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) 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) chatDao.touchSession(sessionId)
} }
return answer return result
} }
/** Системный промпт (наследственный путь, без [AgentToolkit]). */ /** Системный промпт (наследственный путь, без [AgentToolkit]). */
@@ -131,23 +139,6 @@ class Assistant(
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */ /** Общий лимит LLM-вызовов на фразу (включая невидимые). */
const val MAX_LLM_CALLS = 8 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 символов). */ /** Случайное имя сессии (UUID-подобное, 8 символов). */
private fun randomTitle(): String = private fun randomTitle(): String =
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("") "сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
@@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import pw.binom.viewmate.phone.log
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -26,6 +27,106 @@ interface LlmClient {
* [system] — системный промпт тулкита/настроек или null (не обязателен). * [system] — системный промпт тулкита/настроек или null (не обязателен).
*/ */
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String 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", "Продолжай.") if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto)) 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() val request = Request.Builder()
.url("$baseUrl/v1/chat/completions") .url("$baseUrl/v1/chat/completions")
.header("Authorization", "Bearer $apiKey") .header("Authorization", "Bearer $apiKey")
@@ -115,11 +218,34 @@ class RemoteLlmClient(
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull() val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}") throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
} }
val text = runCatching { val reply = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull() }.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 package pw.binom.viewmate.phone.agent
import com.google.ai.edge.litertlm.Backend import pw.binom.litert.LiteBackend
import com.google.ai.edge.litertlm.Content import pw.binom.litert.LiteConfig
import com.google.ai.edge.litertlm.Contents import pw.binom.litert.LiteConversation
import com.google.ai.edge.litertlm.Conversation import pw.binom.litert.LiteConversationConfig
import com.google.ai.edge.litertlm.ConversationConfig import pw.binom.litert.LiteExperimental
import com.google.ai.edge.litertlm.Engine import pw.binom.litert.LiteLlm
import com.google.ai.edge.litertlm.EngineConfig import pw.binom.litert.LiteMessage
import com.google.ai.edge.litertlm.ExperimentalApi import pw.binom.litert.LiteTool
import com.google.ai.edge.litertlm.ExperimentalFlags import pw.binom.litert.LiteToolCall
import com.google.ai.edge.litertlm.Message import pw.binom.litert.google.googleLiteLlm
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout 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.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File import java.io.File
@@ -75,8 +82,12 @@ object Gemma4E2B {
* Ключ: cacheDir должен быть системным applicationContext.cacheDir — * Ключ: cacheDir должен быть системным applicationContext.cacheDir —
* GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров; * GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров;
* свой подкаталог в filesDir приводил к падению на delegate_kernel.cc. * свой подкаталог в 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, modelPath = modelFile.absolutePath,
// NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497) // NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497)
// в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на // в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на
@@ -85,10 +96,11 @@ object Gemma4E2B {
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому // vision-путь не трогает (буфер аллокируется только при картинке), поэтому
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на // берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
// картинке — вернём vision на CPU точечно. // картинке — вернём vision на CPU точечно.
backend = Backend.GPU(), backend = LiteBackend.GPU,
visionBackend = Backend.GPU(), visionBackend = LiteBackend.GPU,
audioBackend = Backend.CPU(), audioBackend = LiteBackend.CPU,
cacheDir = cacheDir.absolutePath, cacheDir = cacheDir.absolutePath,
experimental = LiteExperimental(enableSpeculativeDecoding = true),
) )
} }
@@ -115,53 +127,53 @@ fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */ /** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
class LocalLlmPrompt( class LocalLlmPrompt(
val systemInstruction: String?, val systemInstruction: String?,
val initialMessages: List<Message>, val initialMessages: List<LiteMessage>,
val incoming: Message, val incoming: LiteMessage,
) )
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
.joinToString("") { it.text }
/** /**
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция): * Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
* - USER → [Message.user], ASSISTANT → [Message.model]; * - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model];
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат * - TOOL (результат тула) — как user-сообщение в том же формате «Результат
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет * тула <id>: <текст>», что шлёт [RemoteLlmClient] (консервативно: общая
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж); * с remote-клиентом текстовая конвенция; нативная роль LiteRole.TOOL —
* задел на будущее, когда шаблон модели будет уметь родные тулзы);
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН * - SYSTEM-сообщения и параметр [system] складываются в ОДИН
* systemInstruction (в LiteRT-LM один системный промпт на разговор); * systemInstruction (в LiteRT-LM один системный промпт на разговор);
* - пустые content отбрасываются. * - пустые content отбрасываются.
* *
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL: * Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL.
* оно уходит в sendMessageAsync, остальное — история (initialMessages). * В RAW-режиме (контракт pw.binom.litert) владелец истории — потребитель:
* каждый инференс — новый полный промпт, движок не несёт состояния между
* вызовами; будущие механизмы чистки/сжатия истории для движка — просто
* новый (короткий) полный промпт.
* Если последние сообщения только модельные/история пуста — incoming = «Продолжай.» * Если последние сообщения только модельные/история пуста — incoming = «Продолжай.»
* (аналог fallback [RemoteLlmClient]). * (аналог fallback [RemoteLlmClient]).
*/ */
fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt { fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt {
val systemParts = ArrayList<String>() val systemParts = ArrayList<String>()
system?.takeIf { it.isNotBlank() }?.let { systemParts += it } system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
val mapped = ArrayList<Message>() val mapped = ArrayList<LiteMessage>()
for (msg in messages) { for (msg in messages) {
val text = msg.content val text = msg.content
when (msg.role) { when (msg.role) {
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
ChatRole.USER -> if (text.isNotBlank()) mapped += Message.user(Contents.of(text)) ChatRole.USER -> if (text.isNotBlank()) mapped += LiteMessage.user(text)
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(text)) ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += LiteMessage.model(text)
ChatRole.TOOL -> if (text.isNotBlank()) { 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 last = messages.lastOrNull { it.content.isNotBlank() }
val incoming: Message val incoming: LiteMessage
val initial: List<Message> val initial: List<LiteMessage>
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) { if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
initial = mapped.dropLast(1) initial = mapped.dropLast(1)
incoming = mapped.last() incoming = mapped.last()
} else { } else {
initial = mapped initial = mapped
incoming = Message.user(Contents.of("Продолжай.")) incoming = LiteMessage.user("Продолжай.")
} }
return LocalLlmPrompt( return LocalLlmPrompt(
systemInstruction = systemParts.joinToString("\n").ifEmpty { null }, 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). */ /** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
@@ -281,12 +342,15 @@ class GemmaModelDownloader(
/** /**
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве * Локальный 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 нет — она в наших * новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским * (системная инструкция + вся история + входящее) и схемы тул,
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
* сообщением («локальная модель не скачана…») — [Assistant] покажет его * сообщением («локальная модель не скачана…») — [Assistant] покажет его
* пользователю, падение приложения исключено. * пользователю, падение приложения исключено.
* *
@@ -298,96 +362,124 @@ class LocalLlmClient(
private val cacheDir: File, private val cacheDir: File,
) : LlmClient { ) : LlmClient {
private val engineLock = Any() private val llmLock = Any()
@Volatile @Volatile
private var engine: Engine? = null private var llm: LiteLlm? = null
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */ /** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
fun modelDownloaded(): Boolean = modelFile.isFile fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String { override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)") chatWithTools(messages, emptyList(), system).text
val engine = ensureEngine()
/**
* Чат с тул-каналом: [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: движок готов, строю промпт") log("llm", "chat: движок готов, строю промпт")
val prompt = buildLocalLlmPrompt(messages, system) val prompt = buildLocalLlmPrompt(messages, system)
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text().take(60)}") 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( logChunked("llm", "local: system", prompt.systemInstruction ?: "(нет)")
ConversationConfig( prompt.initialMessages.forEachIndexed { i, m ->
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) }, logChunked("llm", "local: msg[$i/${prompt.initialMessages.size}] ${m.role}", m.text)
initialMessages = prompt.initialMessages,
)
)
} }
log("llm", "chat: conversation создан") 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 { try {
var text = ""
var chunks = 0
var tokens = 0
val t0 = System.currentTimeMillis()
var firstTokenAt = 0L
withTimeout(120_000) { withTimeout(120_000) {
// NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт conversation = llm.createConversation(config)
// emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом // Поток либы сам себя не завершает (callbackFlow живёт до отмены):
// dex null — артефакт маски $default, не аргумент. // остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk -> conversation.sendStream(prompt.incoming.text)
chunks++ .onEach { delta ->
val chunkText = messageChunk.text() chunks++
text += chunkText val chunkText = delta.text
if (chunkText.isNotEmpty()) { text += chunkText
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis() if (chunkText.isNotEmpty()) {
tokens += 1 if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
tokens += 1
}
if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls
if (chunks <= 3 || chunks % 20 == 0) {
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}${if (delta.toolCalls.isNotEmpty()) " [тулы: ${delta.toolCalls.map { it.name }}]" else ""}")
}
} }
if (chunks <= 3 || chunks % 20 == 0) { .takeWhile { !it.isDone }
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}") .collect { }
}
}
} }
val totalMs = System.currentTimeMillis() - t0 val totalMs = System.currentTimeMillis() - t0
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0 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}мс)") log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
return text.trim() 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) { } catch (e: CancellationException) {
log("llm", "chat: отменено") log("llm", "chat: отменено")
runCatching { conversation.cancelProcess() }
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}") log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
log("llm", "chat: EXC msg=${sanitize(e.message)}") log("llm", "chat: EXC msg=${sanitize(e.message)}")
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e) throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
} finally { } finally {
runCatching { conversation.close() } // либа (RAW-путь) закрывает беседу сама, здесь — явный close для
// прямого createConversation: каждый вызов — своя беседа
runCatching { conversation?.close() }
} }
} }
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */ /** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
fun close() { fun close() {
val eng = synchronized(engineLock) { val local = synchronized(llmLock) {
val e = engine val l = llm
engine = null llm = null
e l
} }
runCatching { eng?.close() } runCatching { local?.close() }
}
/** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */
@OptIn(ExperimentalApi::class)
private fun applyExperimentalFlags() {
// NOTE 2026-08-23 (porfiry): ранее выключали спекуляцию, потому что статический
// TopK-сэмплер при enableSpeculativeDecoding=false тянет OpenCL-драйвер,
// которого нет в app-namespace ("Can not find OpenCL library"). Автор включил
// её TRUE: тогда движок использует статический WebGPU-сэмплер (он на HONOR
// работает — оригинал идёт, TTFB ~0.8с тёплый). Берём его флаг как есть.
ExperimentalFlags.enableSpeculativeDecoding = true
} }
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */ /** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
private fun ensureEngine(): Engine { private fun ensureLlm(): LiteLlm {
val cached = engine val cached = llm
if (cached != null) return cached if (cached != null) return cached
return synchronized(engineLock) { return synchronized(llmLock) {
val existing = engine val existing = llm
if (existing != null) return existing if (existing != null) return existing
if (!modelFile.isFile) { if (!modelFile.isFile) {
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»") throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
@@ -395,21 +487,16 @@ class LocalLlmClient(
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) { if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново") throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
} }
applyExperimentalFlags()
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет // cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
// скомпилированные шейдеры в cacheDir; если каталога нет — движок // скомпилированные шейдеры в cacheDir; если каталога нет — движок
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд // падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
// в кэш не пишет, поэтому работал и без mkdirs. // в кэш не пишет, поэтому работал и без mkdirs.
cacheDir.mkdirs() cacheDir.mkdirs()
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir)) // Экспериментальные флаги (speculative decoding → WebGPU-сэмплер)
try { // ушли в LiteConfig (см. [Gemma4E2B.liteConfig]).
created.initialize() val created = googleLiteLlm(Gemma4E2B.liteConfig(modelFile, cacheDir))
} catch (e: Exception) { log("llm", "локальный движок готов: ${modelFile.name}")
runCatching { created.close() } llm = created
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
}
log("llm", "локальный движок инициализирован: ${modelFile.name}")
engine = created
created created
} }
} }
@@ -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)
}
@@ -1,15 +1,12 @@
package pw.binom.viewmate.phone.stt package pw.binom.viewmate.phone.stt
import android.content.Context import android.content.Context
import android.util.Log
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */ /** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
object SttDebug { object SttDebug {
private const val TAG = "stt"
/** /**
* Распознаёт [wavPath] по фразам (VAD + Whisper). * Распознаёт [wavPath] по фразам (VAD + Whisper).
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null. * Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
@@ -23,16 +20,24 @@ object SttDebug {
// через: adb push /data/local/tmp/... + run-as pkg cp // через: adb push /data/local/tmp/... + run-as pkg cp
val modelsDir = File(context.filesDir, "models") val modelsDir = File(context.filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath val convFrontend = File(modelsDir, "conv_frontend.onnx").absolutePath
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath val encoder = File(modelsDir, "encoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "small-tokens.txt").absolutePath val decoder = File(modelsDir, "decoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "tokenizer").absolutePath
log("[stt] создаём VAD...") log("[stt] создаём VAD...")
val segments = VadSegmenter.vadSegments(wav.samples, vadModel) val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
log("[stt] фраз (VAD): ${segments.size}") log("[stt] фраз (VAD): ${segments.size}")
log("[stt] создаём Whisper-small (int8, CPU)...") log("[stt] создаём Qwen3-ASR-0.6B (int8, CPU)...")
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4) val stt = Qwen3AsrStt(
convFrontendPath = convFrontend,
encoderPath = encoder,
decoderPath = decoder,
tokenizerDir = tokens,
numThreads = 4,
hotwords = "тулсет,тулы,умеешь",
)
val parts = ArrayList<String>() val parts = ArrayList<String>()
for ((i, seg) in segments.withIndex()) { for ((i, seg) in segments.withIndex()) {
val startMs = seg.first / 16 val startMs = seg.first / 16
@@ -46,14 +51,14 @@ object SttDebug {
log("[stt] ВЕСЬ ТЕКСТ: $all") log("[stt] ВЕСЬ ТЕКСТ: $all")
all all
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "[stt] ошибка: ${t.message}", t) log("[stt] ошибка: ${t.message}")
t.printStackTrace() t.printStackTrace()
null null
} }
} }
private fun log(msg: String) { private fun log(msg: String) {
Log.i(TAG, msg) pw.binom.viewmate.phone.log("stt", msg)
println(msg) println(msg)
} }
} }
@@ -6,62 +6,11 @@ import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import kotlin.math.sqrt import pw.binom.viewmate.core.stt.s16leToFloat
import pw.binom.viewmate.core.stt.splitPhrases
import pw.binom.viewmate.core.stt.trimEdges
import pw.binom.viewmate.phone.log import pw.binom.viewmate.phone.log
/** 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
}
/**
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
*
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
* Чистая функция (без Android) — покрыта JVM-тестами.
*/
internal fun trimEdges(
samples: FloatArray,
threshold: Float = 0.01f,
windowSize: Int = 320,
padWindows: Int = 15,
): FloatArray {
if (samples.isEmpty()) return samples
var first = -1
var lastEnd = -1
var idx = 0
while (idx < samples.size) {
val end = minOf(idx + windowSize, samples.size)
var sum = 0.0
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
val rms = sqrt(sum / (end - idx)).toFloat()
if (rms >= threshold) {
if (first == -1) first = idx
lastEnd = end
}
idx = end
}
if (first == -1) return FloatArray(0)
val pad = padWindows * windowSize
val start = maxOf(0, first - pad)
val stop = minOf(samples.size, lastEnd + pad)
return samples.copyOfRange(start, stop)
}
/** /**
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз — * Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом * распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
@@ -178,11 +127,13 @@ class SttStreamer(
} }
/** /**
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком, * Распознать весь буфер сессии [sessionPcm]: целиком, если ≤
* если ≤ [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] * [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] сэмплов
* сэмплов (команда перед кликом точно влезает). Перед распознаванием * (команда перед кликом точно влезает). Перед распознаванием применяется
* применяется [trimEdges]. Результат перезаписывает [full]. * [trimEdges], затем [splitPhrases] режет буфер на фразы по паузам — каждая
* Вызывать с удержанным [lock]. * фраза распознаётся отдельно (whisper принимает волны только ≤ 30 с),
* непустые результаты склеиваются через пробел и перезаписывают [full].
* Все фразы мусорные → [full] НЕ трогается. Вызывать с удержанным [lock].
*/ */
private fun recognizeFullBufferLocked() { private fun recognizeFullBufferLocked() {
val buffer = sessionPcm val buffer = sessionPcm
@@ -192,22 +143,32 @@ class SttStreamer(
} else buffer } else buffer
val chunk = trimEdges(base) val chunk = trimEdges(base)
if (chunk.isEmpty()) return if (chunk.isEmpty()) return
val phrases = splitPhrases(chunk)
if (phrases.isEmpty()) return
val sessionId = session val sessionId = session
val sizes = phrases.map { "%.1fс".format(it.size / SAMPLE_RATE.toFloat()) }.joinToString(", ")
val task = executor.submit { val task = executor.submit {
if (sessionId != session) return@submit if (sessionId != session) return@submit
val result = runCatching { stt.recognize(chunk) } runCatching {
if (result.isSuccess) { val parts = ArrayList<String>()
full = result.getOrThrow() for (ph in phrases) {
log( val text = stt.recognize(ph).trim()
"stt", if (text.isNotBlank()) parts.add(text)
"полный буфер: ${chunk.size} сэмплов (${chunk.size / SAMPLE_RATE.toFloat()}с) → '$full'", }
) if (parts.isNotEmpty()) full = parts.joinToString(" ")
} else { parts
log("stt", "полный буфер: ошибка распознавания: ${result.exceptionOrNull()?.message}") }.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 lastFuture = task
runCatching { task.get(30, TimeUnit.SECONDS) } runCatching { task.get(60, TimeUnit.SECONDS) }
} }
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */ /** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
@@ -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",
) : PhraseRecognizer {
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. Возвращает текст. */
override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}
@@ -0,0 +1,211 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.protocol.DownloadPhase as GlassesDownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress as GlassesDownloadProgress
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.DownloadPhase as PhoneDownloadPhase
import pw.binom.viewmate.phone.DownloadProgress as PhoneDownloadProgress
/** Статусы зеркала, которые считаются «конвертируется прямо сейчас». */
private val CONVERTING_STATUSES = setOf("new", "queued", "processing")
/** Как часто опрашиваем mirror.list, пока открыт экран (конвертация не шлёт события сама). */
private const val REFRESH_MS = 15_000L
/** Строка активности для единого списка в LazyColumn. */
private sealed interface ActivityRow {
val key: String
data class Header(val text: String, override val key: String) : ActivityRow
data class Convert(val itemId: String, val title: String, val statusLabel: String, override val key: String) : ActivityRow
data class Download(val itemId: String, val title: String, val kind: String, val percent: Int, override val key: String) : ActivityRow
}
/**
* Экран «Активность»: всё, что сейчас конвертируется (зеркала) и скачивается
* (звук → телефон, видео → очки). Уровень 1: без серверной доработки — по
* конвертации спиннер (progress на сервере не живой), по скачиванию живой %.
*/
@Composable
fun ActivityScreen() {
val app = PhoneApp.instance
val userId by app.userId.collectAsState()
// Конвертирующиеся зеркала: опрос mirror.list раз в REFRESH_MS (нет push от сервера).
var converting by remember { mutableStateOf<List<MirrorJob>>(emptyList()) }
LaunchedEffect(Unit) {
while (isActive) {
converting = runCatching {
app.mirror.list(limit = 500).filter { it.status in CONVERTING_STATUSES }
}.getOrDefault(converting)
delay(REFRESH_MS)
}
}
// Живой прогресс скачивания: звук (телефон) — DownloadManager, видео (очки) — хаб.
val phoneDownloads by app.downloadManager.downloads.collectAsState()
val activePhone by app.downloadManager.activeIds.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
// Настройки/названия предметов по активным itemId (подтягиваем только недостающие).
val activeIds = remember(converting, activePhone, glassesDownloads) {
(converting.mapNotNull { it.itemId } + activePhone + glassesDownloads.keys.filter { it.isNotBlank() })
.distinct()
}
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(activeIds, userId) {
if (userId.isBlank()) return@LaunchedEffect
activeIds.forEach { id ->
if (titles.containsKey(id)) return@forEach
val name = runCatching { app.jellyfin.item(userId, id)?.Name }.getOrNull()
titles = titles + (id to (name?.takeIf { it.isNotBlank() } ?: id))
}
}
val rows = buildActivityRows(
converting = converting,
phone = phoneDownloads,
activePhone = activePhone,
glasses = glassesDownloads,
titles = titles,
)
if (rows.isEmpty()) {
ScreenPlaceholder("Ничего не конвертируется и не скачивается")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
) {
items(rows) { row ->
ActivityRowView(row)
}
}
}
}
private fun buildActivityRows(
converting: List<MirrorJob>,
phone: Map<String, PhoneDownloadProgress>,
activePhone: Set<String>,
glasses: Map<String, Map<String, GlassesDownloadProgress>>,
titles: Map<String, String>,
): List<ActivityRow> {
val out = ArrayList<ActivityRow>()
val name = { id: String -> titles[id] ?: id }
val liveConverting = converting.filter { !it.itemId.isNullOrBlank() }
if (liveConverting.isNotEmpty()) {
out += ActivityRow.Header("Конвертируется", "h-conv")
liveConverting.forEach { job ->
val id = job.itemId.orEmpty()
val label = when (job.status) {
"processing" -> "обрабатывается"
"queued" -> "в очереди"
"new" -> "взято в работу"
else -> job.status ?: "…"
}
out += ActivityRow.Convert(id, name(id), label, "conv-${job.id}")
}
}
val phoneActive = phone.filterKeys { activePhone.contains(it) }
val glassesActive = glasses.filter { (itemId, files) ->
itemId.isNotBlank() && files.values.any { it.phase == GlassesDownloadPhase.DOWNLOADING }
}
if (phoneActive.isNotEmpty() || glassesActive.isNotEmpty()) {
out += ActivityRow.Header("Скачивается", "h-dl")
phoneActive.forEach { (id, p) ->
out += ActivityRow.Download(id, name(id), "звук", p.percent, "dl-$id-phone")
}
glassesActive.forEach { (id, files) ->
val video = files["video.mkv"] ?: files.values.firstOrNull()
out += ActivityRow.Download(id, name(id), "видео", video?.percent ?: 0, "dl-$id-glasses")
}
}
return out
}
@Composable
private fun ActivityRowView(row: ActivityRow) {
when (row) {
is ActivityRow.Header -> SectionLabel(
text = row.text,
modifier = Modifier.padding(top = 10.dp),
)
is ActivityRow.Convert -> StatusBlock {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
color = PhoneAccent,
strokeWidth = 2.dp,
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.title,
color = PhoneTextDim,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "зеркало: ${row.statusLabel}",
color = PhoneMuted,
fontSize = 10.sp,
)
}
}
}
is ActivityRow.Download -> StatusBlock {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = row.title,
modifier = Modifier.weight(1f),
color = PhoneTextDim,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${row.kind} ${row.percent}%",
color = PhoneMuted,
fontSize = 11.sp,
)
}
Spacer(Modifier.height(6.dp))
ProgressBar(percent = row.percent)
}
}
}
}
@@ -0,0 +1,211 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.PhoneConfig
import pw.binom.viewmate.phone.S3Presigner
import pw.binom.viewmate.phone.log
/** Статусы зеркала, при которых конвертацию заказывать не надо. */
private val MIRROR_BUSY_OR_DONE = setOf("done", "queued", "processing")
/**
* Массовые действия страницы сезона/сериала: все эпизоды отправить на конвертацию,
* скачать всё (звук → телефон, видео → очки) и удалить локальные файлы (телефон + очки).
*
* [episodes] — целевой набор эпизодов, [mirrors] — свежие статусы зеркал,
* [label] — «сезон»/«сериал» для подписи, [contextName] — название для диалога удаления.
*/
@Composable
fun BulkActionsRow(
episodes: List<JellyfinItem>,
mirrors: Map<String, MirrorJob>,
label: String,
contextName: String,
onMessage: (String) -> Unit,
onStateRefresh: () -> Unit,
) {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
val dm = app.downloadManager
val phoneDownloaded by dm.downloadedIds.collectAsState()
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
var busy by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) }
val statusOf: (JellyfinItem) -> String? = { mirrors[it.Id]?.status }
val needConversion = episodes.filter { statusOf(it) !in MIRROR_BUSY_OR_DONE }
val readyToDownload = episodes.filter { statusOf(it) == "done" }
/** Если всё уже конвертировано — кнопку «конвертировать» не рисуем. */
val convertable = needConversion.isNotEmpty()
/** Готовые зеркала, которые ещё не скачаны (телефон/очки); если все скачано — кнопку не рисуем. */
val toDownload = readyToDownload.filterNot { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
val withLocal = episodes.count { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
/** Массовая конвертация: заказ по каждому эпизоду без активного/готового зеркала. */
fun onConvert() {
if (needConversion.isEmpty() || busy) return
busy = true
scope.launch {
var ok = 0
var fail = 0
for (ep in needConversion) {
runCatching { app.mirror.createMirror(ep.Id) }
.onSuccess { ok++ }
.onFailure {
fail++
log("bulk", "mirror ${ep.Id}: ${it.message}")
}
}
busy = false
onStateRefresh()
onMessage(
buildString {
append("Конвертация: заказано $ok")
if (fail > 0) append(", ошибок $fail")
append(" ($label)")
},
)
}
}
/**
* Массовое скачивание готовых зеркал: звук на телефон (DownloadManager),
* видео на очки (DownloadFiles через actions). Очки не подключены — только звук на телефон.
*/
fun onDownload() {
if (toDownload.isEmpty() || busy) return
busy = true
scope.launch {
val actions = app.actions
val glassesOk = app.server.hub.connected.value > 0 && actions != null
var videoSent = 0
for (ep in toDownload) {
dm.startDownload(ep.Id)
if (glassesOk) {
val result = runCatching {
actions.downloadToGlasses(ep.Id, sizes = dm.knownFileSizes(ep.Id)) {
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
}
if (result.isFailure) log("bulk", "скачивание на очки ${ep.Id}: ${result.exceptionOrNull()?.message}")
else videoSent++
}
}
busy = false
onStateRefresh()
onMessage(
buildString {
append("Скачивание: звук → телефон, видео → очки ($videoSent) ($label)")
if (!glassesOk) append(" [очки не подключены — только телефон]")
},
)
}
}
/** Удаление локальных файлов всех эпизодов: диск телефона + каталог контента очков. */
fun onDelete() {
if (withLocal == 0 || busy) return
busy = true
scope.launch {
var deleted = 0
for (ep in episodes) {
if (ep.Id !in phoneDownloaded && ep.Id !in glassesDownloadedIds) continue
dm.delete(ep.Id)
app.actions?.deleteDownloaded(ep.Id)
deleted++
}
busy = false
onStateRefresh()
onMessage("Удалено локальное: $deleted эпизод(ов) ($label)")
}
}
if (convertable || toDownload.isNotEmpty() || withLocal > 0) {
Text(
text = "все эпизоды $label",
color = PhoneMuted,
fontSize = 10.sp,
modifier = Modifier.padding(start = 14.dp, top = 4.dp, end = 14.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (convertable) {
OutlinedButton(onClick = ::onConvert, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "конвертировать",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (toDownload.isNotEmpty()) {
OutlinedButton(onClick = ::onDownload, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "скачать",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (withLocal > 0) {
OutlinedButton(onClick = { confirmDelete = true }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "удалить локальное ($withLocal)",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Удалить локальные файлы?") },
text = {
Text(
"«$contextName»: с телефонов и очков удалятся уже скачанные " +
"видео/звук по ${withLocal} эпизод(ам). Это действие необратимо.",
fontSize = 12.sp,
)
},
confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Удалить") }
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
},
)
}
}
@@ -1,5 +1,6 @@
package pw.binom.viewmate.phone.ui package pw.binom.viewmate.phone.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -16,7 +18,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,6 +31,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -43,7 +49,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.WatchProgress
import pw.binom.viewmate.phone.log
/** Вкладки каталога: Фильмы / Сериалы / Папки. */ /** Вкладки каталога: Фильмы / Сериалы / Папки. */
enum class CatalogTab { MOVIES, SERIES, FOLDERS } enum class CatalogTab { MOVIES, SERIES, FOLDERS }
@@ -83,6 +92,8 @@ class CatalogState(
private set private set
var scrollOffset by mutableStateOf(0) var scrollOffset by mutableStateOf(0)
private set private set
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
var onlyDownloaded by mutableStateOf(false)
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */ /** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>() private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
@@ -195,7 +206,6 @@ class CatalogState(
} }
} }
items = loaded items = loaded
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -204,6 +214,15 @@ class CatalogState(
} finally { } finally {
loading = false loading = false
} }
// Зеркала — отдельный «мягкий» запрос: сбой/таймаут mirror.list не чистит
// уже загруженный каталог, просто временно без бейджей.
try {
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("catalog", "зеркала: не удалось (${e.message}) — каталог без бейджей")
}
} }
} }
@@ -230,6 +249,36 @@ fun CatalogScreen(
) { ) {
val app = PhoneApp.instance val app = PhoneApp.instance
val userId by app.userId.collectAsState() val userId by app.userId.collectAsState()
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
// Системная кнопка «назад»: выход из вложенного пути (сериал→сезон, папки) на уровень выше
BackHandler(enabled = state.canGoBack()) {
state.back()
}
// Все эпизоды сериала (для bulk-операций): по каждому сезону — список эпизодов.
var seriesEpisodes by remember { mutableStateOf<List<JellyfinItem>?>(null) }
val seasonIds = state.items.map { it.Id }
LaunchedEffect(state.path, seasonIds, userId) {
if (state.path !is CatalogState.Path.Series || userId.isBlank() || state.items.isEmpty()) {
seriesEpisodes = emptyList()
return@LaunchedEffect
}
seriesEpisodes = state.items.flatMap { season ->
runCatching { app.jellyfin.items(userId, season.Id, listOf("Episode")) }.getOrDefault(emptyList())
}
}
val bulkEpisodes = when (state.path) {
is CatalogState.Path.Season -> state.items
is CatalogState.Path.Series -> seriesEpisodes ?: emptyList()
else -> emptyList()
}
val bulkLabel = when (state.path) {
is CatalogState.Path.Season -> "сезона"
is CatalogState.Path.Series -> "сериала"
else -> ""
}
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями. // Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
@@ -247,6 +296,12 @@ fun CatalogScreen(
} }
val downloaded by app.state.downloadedFlow.collectAsState() val downloaded by app.state.downloadedFlow.collectAsState()
val localDownloaded by app.downloadManager.downloadedIds.collectAsState() val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
// Статус видео на очках: скачанные itemId (свежий GlassesStatus) + живой прогресс
// DownloadProgress (видео очки качают сами, звук — телефон).
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
// «Просмотрено» по элементам (watchprogress.json): тонкий бар на плашке + %/время у названия
val watched by app.watchProgress.flow.collectAsState()
// подтянуть список, когда появился пользователь Jellyfin // подтянуть список, когда появился пользователь Jellyfin
LaunchedEffect(userId) { LaunchedEffect(userId) {
@@ -255,12 +310,40 @@ fun CatalogScreen(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
CatalogTopBar(state = state) CatalogTopBar(state = state)
if (bulkEpisodes.isNotEmpty()) {
BulkActionsRow(
episodes = bulkEpisodes,
mirrors = state.mirrors,
label = bulkLabel,
contextName = state.subtitle(),
onMessage = { message -> scope.launch { snackbar.showSnackbar(message) } },
onStateRefresh = state::refresh,
)
}
// фильтр «только скачанные (и частично)» — остаётся только то, что скачано на каком-то устройстве
val visibleItems: List<JellyfinItem> = if (state.onlyDownloaded) {
state.items.filter { item ->
val id = item.Id
downloaded.containsKey(id) ||
localDownloaded.contains(id) ||
glassesDownloadedIds.contains(id) ||
glassesDownloads.containsKey(id)
}
} else {
state.items
}
if (state.loading && state.items.isEmpty()) { if (state.loading && state.items.isEmpty()) {
ScreenPlaceholder("загружаю…") ScreenPlaceholder("загружаю…")
} else if (state.error != null && state.items.isEmpty()) { } else if (state.error != null && state.items.isEmpty()) {
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить") ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
} else if (state.items.isEmpty()) { } else if (visibleItems.isEmpty()) {
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто") ScreenPlaceholder(
when {
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
state.query.isNotBlank() -> "ничего не нашлось"
else -> "пусто"
},
)
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
state = gridState, state = gridState,
@@ -270,15 +353,20 @@ fun CatalogScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(state.items, key = { it.Id }) { item -> items(visibleItems, key = { it.Id }) { item ->
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
ItemCard( ItemCard(
item = item, item = item,
posterUrl = posterUrlOrNull(app, item), posterUrl = posterUrlOrNull(app, item),
progress = watched[item.Id],
badge = mirrorBadge( badge = mirrorBadge(
itemId = item.Id, itemId = item.Id,
job = state.mirrors[item.Id], job = state.mirrors[item.Id],
downloaded = downloaded, downloaded = downloaded,
localDownloaded = localDownloaded, localDownloaded = localDownloaded,
glassesDownloading = videoProgress?.phase == DownloadPhase.DOWNLOADING,
glassesDownloadingPercent = videoProgress?.percent,
glassesDownloaded = item.Id in glassesDownloadedIds,
), ),
onClick = { onClick = {
when { when {
@@ -303,6 +391,7 @@ fun CatalogScreen(
} }
} }
} }
SnackbarHost(hostState = snackbar)
} }
} }
@@ -359,6 +448,21 @@ private fun CatalogTopBar(state: CatalogState) {
} }
} }
} }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = state.onlyDownloaded,
onCheckedChange = { state.onlyDownloaded = it },
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = "только скачанные (и частично)",
color = PhoneMuted,
fontSize = 11.sp,
)
}
} }
} }
@@ -372,9 +476,14 @@ private fun tabLabel(tab: CatalogTab): String = when (tab) {
private fun ItemCard( private fun ItemCard(
item: JellyfinItem, item: JellyfinItem,
posterUrl: String?, posterUrl: String?,
progress: WatchProgress?,
badge: MirrorBadge, badge: MirrorBadge,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
// «Просмотрено»: % — когда известна длительность, иначе время; бар — снизу постера
val percent = progress?.watchedPercent()
val watchedLabel = progress?.takeIf { it.positionMs > 0L }
?.let { p -> percent?.let { "$it%" } ?: msToClock(p.positionMs) }
Column( Column(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@@ -392,6 +501,24 @@ private fun ItemCard(
.padding(bottom = 6.dp) .padding(bottom = 6.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
) )
if (percent != null && percent > 0) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
.background(PhoneLine),
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(percent / 100f)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
.background(PhoneAccent),
)
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.padding(2.dp) .padding(2.dp)
@@ -408,13 +535,26 @@ private fun ItemCard(
) )
} }
} }
Text( Row(
text = item.Name ?: item.Id,
color = PhoneTextDim,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 2.dp), modifier = Modifier.padding(horizontal = 2.dp),
) verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = item.Name ?: item.Id,
color = PhoneTextDim,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (watchedLabel != null) {
Text(
text = watchedLabel,
color = PhoneMuted,
fontSize = 9.sp,
modifier = Modifier.padding(start = 4.dp, bottom = 1.dp),
)
}
}
} }
} }
@@ -1,6 +1,8 @@
package pw.binom.viewmate.phone.ui package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -11,7 +13,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -19,7 +23,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -33,6 +42,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -41,6 +53,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pw.binom.viewmate.phone.PhoneApp 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.ChatRole
import pw.binom.viewmate.phone.agent.LlmModelChoice import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.Session import pw.binom.viewmate.phone.agent.Session
@@ -48,7 +61,9 @@ import pw.binom.viewmate.phone.agent.Session
/** /**
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в * Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
* локального ассистента (та же очередь, что и голос с очков). Управление * локального ассистента (та же очередь, что и голос с очков). Управление
* сессиями: новая, переключение (выпадающий список), сброс текущей. * сессиями: новая, переключение (выпадающий список ИЛИ ступенчатый горизонтальный
* свайп ленты сообщений — на одну сессию за жест, без инерции; TZ-ui-four п.3),
* сброс текущей.
*/ */
@Composable @Composable
fun ChatScreen() { fun ChatScreen() {
@@ -56,7 +71,7 @@ fun ChatScreen() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) } 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 input by remember { mutableStateOf("") }
var sessionMenu by remember { mutableStateOf(false) } var sessionMenu by remember { mutableStateOf(false) }
var activeId by remember { mutableStateOf<Long?>(null) } var activeId by remember { mutableStateOf<Long?>(null) }
@@ -69,7 +84,7 @@ fun ChatScreen() {
val all = dao.listSessions() val all = dao.listSessions()
val target = activeId ?: all.firstOrNull()?.id val target = activeId ?: all.firstOrNull()?.id
val history = target?.let { dao.messagesForSession(it) } ?: emptyList() val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
all to history.map { it.role to it.content } all to history.map(::uiMsgOf)
} }
sessions = sess sessions = sess
activeId = activeId ?: sess.firstOrNull()?.id activeId = activeId ?: sess.firstOrNull()?.id
@@ -97,13 +112,32 @@ fun ChatScreen() {
delay(2_000) delay(2_000)
val id = activeId val id = activeId
if (id != null) { 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 if (fresh != messages) messages = fresh
} }
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s } withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
} }
} }
// Ступенчатое переключение сессий свайпом (TZ-ui-four п.3): фиксированный
// порог смещения в px (плотность экрана).
val thresholdPx = with(LocalDensity.current) { CHAT_SESSION_SWIPE_THRESHOLD.toPx() }
/**
* Ступенчатое переключение: ровно ОДИН шаг (+1 — следующая, −1 — предыдущая),
* без перескока сразу на несколько сессий, без инерции/флинга;
* за границей списка — ничего.
*/
fun switchSessionBy(delta: Int) {
if (sessions.size < 2) return
val idx = sessions.indexOfFirst { it.id == activeId }.let { if (it >= 0) it else 0 }
val target = idx + delta
if (target < 0 || target >= sessions.size) return
val s = sessions[target]
activeId = s.id
app.selectChatSession(s.id)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -123,7 +157,7 @@ fun ChatScreen() {
Box { Box {
TextButton(onClick = { sessionMenu = true }) { TextButton(onClick = { sessionMenu = true }) {
Text( Text(
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…", text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет",
color = PhoneAccent, color = PhoneAccent,
fontSize = 12.sp, fontSize = 12.sp,
) )
@@ -131,7 +165,7 @@ fun ChatScreen() {
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) { DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s -> sessions.forEach { s ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(s.title, fontSize = 12.sp) }, text = { Text(s.title.ifBlank { "нет" }, fontSize = 12.sp) },
onClick = { onClick = {
activeId = s.id activeId = s.id
app.selectChatSession(s.id) app.selectChatSession(s.id)
@@ -152,7 +186,7 @@ fun ChatScreen() {
TextButton(onClick = { TextButton(onClick = {
app.resetChatSession() app.resetChatSession()
activeId?.let { id -> 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 messages = history
} }
}) { }) {
@@ -164,16 +198,57 @@ fun ChatScreen() {
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»). // Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
val llmChoice by app.llmChoice.collectAsState() val llmChoice by app.llmChoice.collectAsState()
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw", Text(
color = PhoneMuted, text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B"
fontSize = 10.sp, else "Серверный LLM · llm.binom.pw",
) color = PhoneMuted,
fontSize = 10.sp,
)
Spacer(Modifier.width(6.dp))
// Переключить бэкенд прямо в чате (без захода в настройки).
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "→ серверный" else "→ локальный",
color = PhoneAccent,
fontSize = 10.sp,
modifier = Modifier
.clickable {
app.setLlmModelChoice(
if (llmChoice == LlmModelChoice.LOCAL) LlmModelChoice.REMOTE
else LlmModelChoice.LOCAL,
)
}
.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
Spacer(Modifier.height(4.dp)) 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()) { if (messages.isEmpty()) {
Text( Text(
text = "Спроси что-нибудь — например, «сколько сейчас время?»", text = "Спроси что-нибудь — например, «сколько сейчас время?»",
@@ -183,8 +258,11 @@ fun ChatScreen() {
) )
} else { } else {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(messages) { (role, content) -> items(messages) { msg ->
MessageBubble(role = role, text = content) when (msg.role) {
ChatRole.TOOL -> ToolBubble(msg)
else -> MessageBubble(role = msg.role, text = msg.content)
}
} }
} }
} }
@@ -207,7 +285,7 @@ fun ChatScreen() {
val text = input.trim() val text = input.trim()
if (text.isEmpty()) return@Button if (text.isEmpty()) return@Button
input = "" input = ""
messages = messages + (ChatRole.USER to text) messages = messages + UiMsg(ChatRole.USER, text)
app.sendToAssistant(text) app.sendToAssistant(text)
}, },
) { ) {
@@ -228,7 +306,9 @@ private fun MessageBubble(role: ChatRole, text: String) {
Text( Text(
text = text, text = text,
color = PhoneText, color = PhoneText,
fontSize = 14.sp, // Базовый шрифт сообщений (TZ-ui-four п.2): крупнее, чтобы читалось
// без напряжения (14.sp → 20.sp, ~+43%); вёртка баллонов не менялась.
fontSize = 20.sp,
modifier = Modifier modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp) .padding(horizontal = 10.dp, vertical = 6.dp)
.background( .background(
@@ -239,3 +319,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 coil.compose.AsyncImage
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorJob
import java.util.Locale
/** Экраны приложения (простая навигация без nav-библиотеки). */ /** Экраны приложения (простая навигация без nav-библиотеки). */
enum class Screen { enum class Screen {
CATALOG, CATALOG,
DETAILS, DETAILS,
ACTIVITY,
GLASSES, GLASSES,
GLASSES_INFO, GLASSES_INFO,
CHAT, CHAT,
@@ -45,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
private val bottomTabs = listOf( private val bottomTabs = listOf(
BottomTab(Screen.CATALOG, "🎬", "Каталог"), BottomTab(Screen.CATALOG, "🎬", "Каталог"),
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
BottomTab(Screen.GLASSES, "🕶️", "Очки"), BottomTab(Screen.GLASSES, "🕶️", "Очки"),
BottomTab(Screen.CHAT, "💬", "Чат"), BottomTab(Screen.CHAT, "💬", "Чат"),
) )
@@ -76,11 +79,13 @@ fun BottomNavBar(current: Screen, onSelect: (Screen) -> Unit) {
data class MirrorBadge(val text: String, val bg: Color, val fg: Color) data class MirrorBadge(val text: String, val bg: Color, val fg: Color)
/** /**
* Статус зеркала элемента: * Статус зеркала элемента (коротко для бейджа каталога):
* - ⟳ N% — конвертируется; * - ⟳ N% — конвертируется;
* - ↓ часть — что-то скачано локально (телефон) или на очки; * - ✗ — ошибка конвертации;
* - ✓ — зеркало готово; * - ↓ N% — видео скачивается на очки;
* - ✗ — ошибка; * - ▶ — можно смотреть (видео уже на очках);
* - ✓ — зеркало готово (ещё не на очках);
* - ↓ часть — скачано что-то локально (телефон) или на очки;
* - нет — ничего нет. * - нет — ничего нет.
*/ */
fun mirrorBadge( fun mirrorBadge(
@@ -88,6 +93,9 @@ fun mirrorBadge(
job: MirrorJob?, job: MirrorJob?,
downloaded: Map<String, String>, downloaded: Map<String, String>,
localDownloaded: Set<String>, localDownloaded: Set<String>,
glassesDownloading: Boolean = false,
glassesDownloadingPercent: Int? = null,
glassesDownloaded: Boolean = false,
): MirrorBadge { ): MirrorBadge {
val status = job?.status val status = job?.status
val hasLocal = localDownloaded.contains(itemId) val hasLocal = localDownloaded.contains(itemId)
@@ -101,6 +109,16 @@ fun mirrorBadge(
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White) status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
// Видео уже летит на очки — конкретнее «✓ готово»
glassesDownloading -> MirrorBadge(
"↓ ${glassesDownloadingPercent ?: 0}%",
PhoneBlue,
PhoneBlueText,
)
// Видео на очках — можно смотреть
glassesDownloaded -> MirrorBadge("▶", PhoneGreen, PhoneGreenText)
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText) status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText) hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
@@ -168,7 +186,7 @@ fun bytesToSize(bytes: Long?): String {
val gb = 1024.0 * 1024 * 1024 val gb = 1024.0 * 1024 * 1024
val mb = 1024.0 * 1024 val mb = 1024.0 * 1024
return when { return when {
bytes >= gb -> "%.2f ГБ".format(bytes / gb) bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
bytes >= mb -> "%d МБ".format((bytes / mb).toLong()) bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
else -> "%d КБ".format((bytes / 1024).toLong()) else -> "%d КБ".format((bytes / 1024).toLong())
} }
@@ -291,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
} }
@Composable @Composable
fun SectionLabel(text: String) { fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text( Text(
text = text, text = text,
color = PhoneMuted, color = PhoneMuted,
@@ -299,6 +317,6 @@ fun SectionLabel(text: String) {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 6.dp), modifier = modifier.padding(bottom = 6.dp),
) )
} }
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.media.MirrorStatus import pw.binom.viewmate.core.media.MirrorStatus
import pw.binom.viewmate.core.phone.trackFileName
import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.FileDownloadState import pw.binom.viewmate.phone.FileDownloadState
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
@@ -79,16 +78,16 @@ fun DetailsScreen(
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState() val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesMediaDir by app.server.hub.mediaDir.collectAsState() val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
// статус зеркала + дорожки; пока конвертируется — опрашиваем // статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
LaunchedEffect(item.Id, refreshKey) { LaunchedEffect(item.Id, refreshKey) {
loading = true loading = true
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull() mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
loading = false loading = false
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") { while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
delay(3_000) delay(3_000)
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull() mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
} }
} }
@@ -179,13 +178,24 @@ fun DetailsScreen(
} }
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам // PlayVideo — только локальный путь на очках, никаких S3-URL очкам
val localPath = "file://$mediaDir/${item.Id}/video.mkv" val localPath = "file://$mediaDir/${item.Id}/video.mkv"
// «Продолжить с того места»: сохранённая позиция (null — не смотрели / досмотрели)
val resumeMs = if (alreadyPlaying) null else app.watchProgress.resumePosition(item.Id)
if (resumeMs != null) {
log("watch", "возобновление с сохранённой позиции: ${resumeMs} мс (${item.Name})")
}
val result: String val result: String
if (alreadyPlaying) { if (alreadyPlaying) {
result = "включил (видео уже играет на очках)" result = "включил (видео уже играет на очках)"
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук") log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
} else { } else {
result = try { result = try {
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk) actions!!.watch(
item.Id,
effectiveAudioIndex,
localVideoPath = localPath,
phoneAudioReady = audioOk,
startPositionMs = resumeMs ?: 0L,
)
} catch (e: Exception) { } catch (e: Exception) {
log("watch", "watch упал: ${e.message}") log("watch", "watch упал: ${e.message}")
snackbar.showSnackbar("watch упал: ${e.message}") snackbar.showSnackbar("watch упал: ${e.message}")
@@ -211,7 +221,7 @@ fun DetailsScreen(
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}") snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
} else { } else {
log("watch", "audio source: ${audio.source} (${audio.reason})") log("watch", "audio source: ${audio.source} (${audio.reason})")
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else (resumeMs ?: 0L)
app.audioSync.play(audio.source, startPositionMs) app.audioSync.play(audio.source, startPositionMs)
} }
} else { } else {
@@ -392,26 +402,28 @@ fun DetailsScreen(
keys.forEach { key -> keys.forEach { key ->
val name = finalFileName(key) val name = finalFileName(key)
val (finalFile, partFile) = trackFiles[key] ?: return@forEach val (finalFile, partFile) = trackFiles[key] ?: return@forEach
val isVideo = key == "video"
val phoneRaw = phoneFileState(finalFile, partFile) val phoneRaw = phoneFileState(finalFile, partFile)
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) { val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id) dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
} else { } else {
phoneRaw.second phoneRaw.second
} }
val glassesState = if (glassesFully) { // Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
FileDownloadState.DONE to 100 val state = when {
} else { isVideo && glassesFully -> FileDownloadState.DONE to 100
glassesFileState(perFileProgress[name]) isVideo -> glassesFileState(perFileProgress[name])
else -> phoneRaw.first to phonePercent
} }
DownloadFileRow( DownloadFileRow(
name = name, name = name,
sizeLabel = fileSizeOrProgress( device = if (isVideo) "очки" else "телефон",
sizeBytes = knownSizeByName[name], sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
phone = phoneRaw.first to phonePercent, "⏳ ${state.second}%"
glasses = glassesState, } else {
), bytesToSize(knownSizeByName[name])
phone = phoneRaw.first to phonePercent, },
glasses = glassesState, state = state,
) )
} }
} }
@@ -498,24 +510,13 @@ fun DetailsScreen(
} }
} }
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */ /** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */
private fun fileSizeOrProgress(
sizeBytes: Long?,
phone: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
): String = when {
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
else -> bytesToSize(sizeBytes)
}
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
@Composable @Composable
private fun DownloadFileRow( private fun DownloadFileRow(
name: String, name: String,
device: String,
sizeLabel: String, sizeLabel: String,
phone: Pair<FileDownloadState, Int>, state: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp) Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
@@ -523,13 +524,9 @@ private fun DownloadFileRow(
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("телефон", color = PhoneMuted, fontSize = 9.sp) Text(device, color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
FileChip(phone) FileChip(state)
Spacer(modifier = Modifier.width(12.dp))
Text("очки", color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp))
FileChip(glasses)
} }
} }
} }
@@ -201,53 +201,60 @@ private fun LlmModelSection() {
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (choice == LlmModelChoice.LOCAL) { // Индикатор локальной модели — независимо от выбора бэкенда: пока модель
when (val s = llmState) { // скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL.
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) { when (val s = llmState) {
Text( LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})", Text(
color = PhoneMuted, text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
fontSize = 11.sp, color = PhoneMuted,
modifier = Modifier.weight(1f),
)
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
}
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
ProgressBar(
percent = s.percent,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
}
Spacer(Modifier.height(4.dp))
Text(
text = "Скачивание модели… ${s.percent}%",
color = PhoneMuted,
fontSize = 10.sp,
)
}
LocalLlmModelState.Ready -> Text(
text = "Модель готова (Gemma 4 E2B, локально)",
color = PhoneGreen,
fontSize = 11.sp, fontSize = 11.sp,
modifier = Modifier.weight(1f),
) )
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Ошибка скачивания: ${s.message}",
color = PhoneRed,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
}
} }
} else {
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
ProgressBar(
percent = s.percent,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
}
Spacer(Modifier.height(4.dp))
Text(
text = "Скачивание локальной модели… ${s.percent}%",
color = PhoneMuted,
fontSize = 10.sp,
)
}
LocalLlmModelState.Ready -> Text(
text = "Локальная модель готова (Gemma 4 E2B)",
color = PhoneGreen,
fontSize = 11.sp,
)
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Ошибка скачивания: ${s.message}",
color = PhoneRed,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
}
}
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
if (
choice == LlmModelChoice.REMOTE &&
llmState !is LocalLlmModelState.Downloading &&
llmState !is LocalLlmModelState.Error
) {
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp) Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
} }
} }
@@ -41,6 +41,7 @@ import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import pw.binom.viewmate.phone.glassesLivePercent import pw.binom.viewmate.phone.glassesLivePercent
/** Экран 3 — Статус очков + пульт. */ /** Экран 3 — Статус очков + пульт. */
@@ -61,6 +62,7 @@ fun GlassesScreen(
val playing by app.state.playingFlow.collectAsState() val playing by app.state.playingFlow.collectAsState()
val mode by app.state.modeFlow.collectAsState() val mode by app.state.modeFlow.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState() val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
val llmState by app.localLlmState.collectAsState()
val durationMs = currentItem?.RunTimeTicks?.div(10_000) val durationMs = currentItem?.RunTimeTicks?.div(10_000)
var showDownloads by remember { mutableStateOf(false) } var showDownloads by remember { mutableStateOf(false) }
@@ -222,6 +224,18 @@ fun GlassesScreen(
) )
} }
// авто-скачивание локальной LLM-модели (Gemma 4 E2B)
val llmModelState = llmState
if (llmModelState is LocalLlmModelState.Downloading) {
Text(
text = "скачивание LLM-модели: ${llmModelState.percent}%",
color = PhoneYellow,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -5,34 +5,61 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.width 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.phone.MercuryBridge import pw.binom.viewmate.phone.MercuryBridge
/** /**
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury. * Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
* Минимум: найти очки → подключить → погасить/включить экран (deepSuspend). * Управление очками: подключение, экран, яркость и WiFi.
*/ */
@Composable @Composable
fun MercuryPanel() { fun MercuryPanel() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val state by MercuryBridge.state.collectAsState() val state by MercuryBridge.state.collectAsState()
val busy = false
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( Column(
modifier = Modifier modifier = Modifier
@@ -43,7 +70,7 @@ fun MercuryPanel() {
.padding(12.dp), .padding(12.dp),
) { ) {
Text( Text(
text = "🛰 Mercury BLE (полевой тест)", text = "🛰 Mercury BLE (управление очками)",
color = PhoneText, color = PhoneText,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@@ -65,12 +92,7 @@ fun MercuryPanel() {
Row(modifier = Modifier.padding(top = 10.dp)) { Row(modifier = Modifier.padding(top = 10.dp)) {
Button( Button(
onClick = { onClick = { scope.launch { MercuryBridge.connectFirst() } },
scope.launch {
MercuryBridge.connectFirst()
}
},
enabled = !busy,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue, containerColor = PhoneBlue,
contentColor = PhoneBlueText, contentColor = PhoneBlueText,
@@ -80,9 +102,7 @@ fun MercuryPanel() {
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { onClick = { scope.launch { MercuryBridge.disconnect() } },
scope.launch { MercuryBridge.disconnect() }
},
enabled = state.connected, enabled = state.connected,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface, containerColor = PhoneSurface,
@@ -95,9 +115,67 @@ fun MercuryPanel() {
Row(modifier = Modifier.padding(top = 8.dp)) { Row(modifier = Modifier.padding(top = 8.dp)) {
Button( Button(
onClick = { onClick = { MercuryBridge.startPairing() },
scope.launch { MercuryBridge.screenOff() } 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, enabled = state.connected,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2B2F36), containerColor = Color(0xFF2B2F36),
@@ -108,9 +186,7 @@ fun MercuryPanel() {
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { onClick = { scope.launch { MercuryBridge.screenOn() } },
scope.launch { MercuryBridge.screenOn() }
},
enabled = state.connected, enabled = state.connected,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = PhoneAccent, containerColor = PhoneAccent,
@@ -120,5 +196,190 @@ fun MercuryPanel() {
Text("☀️ включить экран", fontSize = 12.sp) Text("☀️ включить экран", fontSize = 12.sp)
} }
} }
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.launchApp(MercuryBridge.VIEWMATE_GLASSES_PKG) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Запустить приложение", fontSize = 12.sp)
}
}
// ---- Яркость ----
Text(
text = "Яркость: $brightness%",
color = PhoneText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 12.dp),
)
Slider(
value = brightness.toFloat(),
onValueChange = { brightness = it.roundToInt() },
valueRange = 0f..100f,
enabled = state.connected,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Применить яркость", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = autoBright,
onCheckedChange = { nv ->
autoBright = nv
scope.launch { MercuryBridge.setAutoBrightness(nv) }
},
enabled = state.connected,
)
Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp)
}
// ---- WiFi ----
Row(
modifier = Modifier.padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "WiFi",
color = PhoneText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = wifiOn,
onCheckedChange = { nv ->
wifiOn = nv
scope.launch { MercuryBridge.wifiToggle(nv) }
},
enabled = state.connected,
)
Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp)
Spacer(modifier = Modifier.width(10.dp))
Button(
onClick = { scope.launch {
if (!state.wifiEnabled) MercuryBridge.wifiToggle(true)
scanning = true
delay(1500) // дать радио подняться
MercuryBridge.wifiScan()
delay(5000) // окно ожидания ответа очков
scanning = false
} },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp)
}
if (state.wifiSsid != null) {
Text(
"· ${state.wifiSsid}",
color = PhoneBlueText,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
}
TextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text("SSID сети", fontSize = 11.sp) },
singleLine = true,
enabled = state.connected,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
TextField(
value = wifiPass,
onValueChange = { wifiPass = it },
label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) },
singleLine = true,
enabled = state.connected,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
Button(
onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } },
enabled = state.connected && ssid.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Подключить к сети", fontSize = 12.sp)
}
if (state.wifiNetworks.isEmpty() && !scanning) {
Text(
"Список пуст: включи WiFi и нажми «Сканировать».",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(top = 8.dp),
)
}
if (state.wifiNetworks.isNotEmpty()) {
Text(
"Найдено сетей: ${state.wifiNetworks.size}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(top = 8.dp),
)
LazyColumn(
modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp),
) {
items(state.wifiNetworks) { net ->
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Text(
"${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}",
color = PhoneText,
fontSize = 11.sp,
)
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Button(
onClick = { scope.launch {
val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null
MercuryBridge.wifiConnect(net.ssid, pwd)
} },
enabled = state.connected,
) {
Text("Подкл.", fontSize = 11.sp)
}
if (net.saved) {
Button(
onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text("Забыть", fontSize = 11.sp)
}
}
}
}
}
}
}
} }
} }
@@ -0,0 +1,58 @@
package pw.binom.viewmate.phone.ui
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
/**
* Рендер QR-кода (pairCode) для паринга очков: очки сканируют его своей камерой.
* В стоковом приложении RayNeo QR кодирует ровно pairCode (8 символов).
*/
@Composable
fun QrCode(
text: String,
modifier: Modifier = Modifier,
side: Int = 200,
foreground: Color = Color.Black,
background: Color = Color.White,
) {
val density = LocalDensity.current.density
val sizePx = (side * density).toInt().coerceAtLeast(256)
val bitmap = remember(text, sizePx, foreground, background) {
renderQr(
text = text,
sizePx = sizePx,
foreground = foreground.toArgb(),
background = background.toArgb(),
)
}
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-код паринга",
modifier = modifier.size(side.dp),
)
}
private fun renderQr(text: String, sizePx: Int, foreground: Int, background: Int): Bitmap {
val bits = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx)
val w = bits.width
val h = bits.height
val pixels = IntArray(w * h)
for (y in 0 until h) {
val rowOffset = y * w
for (x in 0 until w) {
pixels[rowOffset + x] = if (bits.get(x, y)) foreground else background
}
}
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
}
@@ -51,6 +51,8 @@ fun ViewMateApp() {
}, },
) )
Screen.ACTIVITY -> ActivityScreen()
Screen.DETAILS -> { Screen.DETAILS -> {
val item = selected val item = selected
if (item != null) { if (item != null) {
@@ -12,58 +12,111 @@ import kotlin.test.assertTrue
class AudioSyncPlayerTest { class AudioSyncPlayerTest {
@Test // --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek ---
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 совпадают — не трогаем")
}
@Test @Test
fun thresholdBoundaryDiff1000DoesNotSeek() { fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true) val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем") assertEquals(1f, d.speed, "полное совпадение — темп не трогаем")
} assertNull(d.seekToMs)
@Test
fun seeksToVideoPositionWhenDiffAboveThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
assertNull(d.playing) assertNull(d.playing)
} }
@Test @Test
fun seeksWhenAudioAheadOfVideo() { fun speedStaysFlatInsideTriggerBand() {
val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true) 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(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 @Test
fun playWhenVideoPlayingButAudioPaused() { fun playWhenVideoPlayingButAudioPaused() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true) 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, "очки играют — аудио должно играть") assertEquals(true, d.playing, "очки играют — аудио должно играть")
assertNull(d.seekToMs)
} }
@Test @Test
fun pauseWhenVideoPausedButAudioPlaying() { fun pauseWhenVideoPausedButAudioPlaying() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false) val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
assertNull(d.seekToMs) assertEquals(1f, d.speed)
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться") assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
assertNull(d.seekToMs)
} }
@Test @Test
fun seekAndPlayApplyTogether() { fun seekAndPlayApplyTogether() {
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true) val d = decideSync(audioPosition = 5_000, videoPosition = 15_000, audioPlaying = false, videoPlaying = true)
assertEquals(8_000L, d.seekToMs) assertEquals(15_000L, d.seekToMs)
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync") assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
} assertEquals(1f, d.speed)
@Test
fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs)
assertNull(d.playing)
} }
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») --- // --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
@@ -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 package pw.binom.viewmate.phone.agent
import com.google.ai.edge.litertlm.Contents import pw.binom.litert.LiteMessage
import com.google.ai.edge.litertlm.Message import pw.binom.litert.LiteRole
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.io.path.createTempFile import kotlin.io.path.createTempFile
@@ -47,12 +47,12 @@ class LlmLocalTest {
// системный промпт = SYSTEM-сообщения (системный параметр не задан) // системный промпт = SYSTEM-сообщения (системный параметр не задан)
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction) assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
// входящее — последнее непустое USER // входящее — последнее непустое USER
assertEquals("погоди, выключи", p.incoming.text()) assertEquals("погоди, выключи", p.incoming.text)
// история: user, assistant, tool-результат в «Результат тула…» формате // история: user, assistant, tool-результат в «Результат тула…» формате
assertEquals(3, p.initialMessages.size) assertEquals(3, p.initialMessages.size)
assertEquals("включи свет", p.initialMessages[0].text()) assertEquals("включи свет", p.initialMessages[0].text)
assertEquals("Свет включён.", p.initialMessages[1].text()) assertEquals("Свет включён.", p.initialMessages[1].text)
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text()) assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text)
} }
@Test @Test
@@ -64,7 +64,7 @@ class LlmLocalTest {
) )
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.") val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction) assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
assertEquals("привет", p.incoming.text()) assertEquals("привет", p.incoming.text)
assertTrue(p.initialMessages.isEmpty()) assertTrue(p.initialMessages.isEmpty())
} }
@@ -77,10 +77,10 @@ class LlmLocalTest {
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"), ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
) )
val p = buildLocalLlmPrompt(history) val p = buildLocalLlmPrompt(history)
assertEquals("Результат тула c7: найдено 5", p.incoming.text()) assertEquals("Результат тула c7: найдено 5", p.incoming.text)
// assistant с пустым текстом в историю не попадает // assistant с пустым текстом в историю не попадает
assertEquals(1, p.initialMessages.size) assertEquals(1, p.initialMessages.size)
assertEquals("что в каталоге?", p.initialMessages[0].text()) assertEquals("что в каталоге?", p.initialMessages[0].text)
} }
@Test @Test
@@ -90,7 +90,7 @@ class LlmLocalTest {
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"), ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
) )
val p = buildLocalLlmPrompt(history) val p = buildLocalLlmPrompt(history)
assertEquals("Продолжай.", p.incoming.text()) assertEquals("Продолжай.", p.incoming.text)
assertEquals(2, p.initialMessages.size) assertEquals(2, p.initialMessages.size)
assertTrue(p.systemInstruction == null) assertTrue(p.systemInstruction == null)
} }
@@ -105,13 +105,15 @@ class LlmLocalTest {
val p = buildLocalLlmPrompt(history) val p = buildLocalLlmPrompt(history)
// пустое user-сообщение отброшено; входящее — «как дела» // пустое user-сообщение отброшено; входящее — «как дела»
assertEquals(1, p.initialMessages.size) assertEquals(1, p.initialMessages.size)
assertEquals("как дела", p.incoming.text()) assertEquals("как дела", p.incoming.text)
} }
@Test @Test
fun messageTextExtractsTextParts() { fun liteMessageFactoriesMapRoles() {
val m = Message.user(Contents.of("привет")) assertEquals(LiteMessage(LiteRole.USER, "привет"), LiteMessage.user("привет"))
assertEquals("привет", m.text()) assertEquals(LiteMessage(LiteRole.MODEL, "здравствуйте"), LiteMessage.model("здравствуйте"))
assertEquals(LiteMessage(LiteRole.SYSTEM, "ты ассистент"), LiteMessage.system("ты ассистент"))
assertEquals(LiteMessage(LiteRole.TOOL, "результат"), LiteMessage.tool("результат"))
} }
// ---------- SHA-256 ---------- // ---------- SHA-256 ----------
@@ -79,7 +79,11 @@ class FullBufferSttTest {
} }
} }
/** 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов, текст не пуст. */ /**
* 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов (текст не пуст).
* Хвост — сплошной зубец без пауз → splitPhrases даёт одну длинную фразу (120 с),
* которая режется страховкой на куски ≤ 30 с: 4 вызова по 30 с.
*/
@Test @Test
fun longBufferPassesLastTwoMinutes() { fun longBufferPassesLastTwoMinutes() {
val recognizer = RecordingRecognizer("ок") val recognizer = RecordingRecognizer("ок")
@@ -92,13 +96,14 @@ class FullBufferSttTest {
off += 1600 off += 1600
} }
val full = streamer.finish() val full = streamer.finish()
assertEquals(1, recognizer.calls.size) assertEquals(4, recognizer.calls.size, "120 с непрерывной речи режется на 4 фразы ≤ 30 с")
val chunk = recognizer.calls[0] val sumLen = recognizer.calls.sumOf { it.size }
assertEquals(16_000 * 120, chunk.size, "ровно хвост 120 с") assertEquals(16_000 * 120, sumLen, "в сумме ровно хвост 120 с")
recognizer.calls.forEach { assertTrue(it.size <= 30 * 16_000, "каждая фраза ≤ 30 с") }
assertContentEquals( assertContentEquals(
FloatArray(16_000 * 120) { expectedFloat(total - 16_000 * 120 + it) }, FloatArray(30 * 16_000) { expectedFloat(total - 16_000 * 120 + it) },
chunk, recognizer.calls[0],
"хвост, а не начало буфера", "первая фраза — начало хвоста, а не начало буфера",
) )
assertTrue(full.isNotBlank(), "результат полного буфера не пуст") assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
} finally { } finally {
@@ -106,6 +111,28 @@ class FullBufferSttTest {
} }
} }
/**
* Две фразы (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(): распознаватель не вызывался, полный текст пуст. */ /** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
@Test @Test
fun resetThenFinishSkipsRecognizer() { fun resetThenFinishSkipsRecognizer() {
@@ -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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import pw.binom.viewmate.core.stt.s16leToFloat
class S16ToFloatTest { class S16ToFloatTest {
@@ -3,6 +3,7 @@ package pw.binom.viewmate.phone.stt
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import pw.binom.viewmate.core.stt.trimEdges
/** /**
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается, * Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
+10
View File
@@ -14,3 +14,13 @@ allprojects {
} }
group = "pw.binom.viewmate" 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 -4
View File
@@ -10,7 +10,7 @@ androidx-activity-compose = "1.10.1"
exoplayer = "2.17.1" exoplayer = "2.17.1"
media3 = "1.6.1" media3 = "1.6.1"
koog = "1.1.1" koog = "1.1.1"
litertlm-android = "0.14.0" litert-google = "5"
[libraries] [libraries]
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
@@ -24,9 +24,15 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" } exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
# LiteRT LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне. # LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
# litertlm-android 0.14.0 нужен на kotlinx-coroutines 1.11.0 (shared версия поднята) # KMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp):
litertlm-android = { module = "com.google.ai.edge.litertlm:litertlm-android", version.ref = "litertlm-android" } # универсальный контракт (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-совместимый) # Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client; # Модели/типы (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 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) { fun log(tag: String, message: String) {
println("[view-mate] $tag $message") 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 (по умолчанию включён). */ /** Флаг debug-логирования HTTP (по умолчанию включён). */
object HttpClientDebugLogging { object HttpClientDebugLogging {
var enabled: Boolean = true var enabled: Boolean = true
@@ -2,6 +2,8 @@ package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body 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.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
@@ -19,7 +21,7 @@ import io.ktor.http.contentType
class MirrorClient( class MirrorClient(
private val baseUrl: String, // https://mirror.binom.pw private val baseUrl: String, // https://mirror.binom.pw
private val apiKey: String, // X-API-Key private val apiKey: String, // X-API-Key
private val http: HttpClient = defaultHttpClient(), private val http: HttpClient = defaultMirrorHttpClient(),
) { ) {
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */ /** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
suspend fun createMirror(itemId: String): MirrorJob { suspend fun createMirror(itemId: String): MirrorJob {
@@ -42,15 +44,26 @@ class MirrorClient(
return response.body() return response.body()
} }
/** Список зеркал: статус (null — все) + лимит. */ /** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> { suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
val response = http.get("$baseUrl/api/mirror") { var last: Exception? = null
header("X-API-Key", apiKey) repeat(LIST_ATTEMPTS) { attempt ->
parameter("limit", limit) try {
if (status != null) parameter("status", status) val response = http.get("$baseUrl/api/mirror") {
header("X-API-Key", apiKey)
parameter("limit", limit)
if (status != null) parameter("status", status)
}
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)
}
} }
response.ensureSuccess("mirror.list") throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
return response.body()
} }
/** Удаление зеркала (204). */ /** Удаление зеркала (204). */
@@ -69,4 +82,11 @@ class MirrorClient(
response.ensureSuccess("mirror.scan") response.ensureSuccess("mirror.scan")
return response.body() return response.body()
} }
companion object {
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
private const val LIST_ATTEMPTS = 2
private const val LIST_RETRY_DELAY_MS = 1_500L
}
} }
@@ -128,8 +128,9 @@ class PhoneActions(
} }
/** /**
* Скачать контент в память очков: DownloadFiles с presigned URL всех файлов * Скачать контент в память очков: DownloadFiles с presigned URL только видео (video.mkv).
* (video.mkv + audio-N.ogg) и известными размерами дорожек [sizes] (key → байт). * Звук на очки не идёт — аудио-дорожки скачиваются только на телефон.
* [sizes] — известные размеры дорожек (key → байт).
*/ */
suspend fun downloadToGlasses( suspend fun downloadToGlasses(
itemId: String, itemId: String,
@@ -86,6 +86,14 @@ data class SetScale(val scale: Float) : HostToGlasses
@SerialName("set_vertical") @SerialName("set_vertical")
data class SetVertical(val vertical: Float) : HostToGlasses data class SetVertical(val vertical: Float) : HostToGlasses
/**
* Индикатор аудио-синхронизации для статус-панели очков: diffMs — |звук − видео| в миллисекундах
* (каждый sync-цикл ~5с), active — сейчас ли видео играет на очках (иначе индикатор скрывают).
*/
@Serializable
@SerialName("audio_sync_info")
data class AudioSyncInfo(val diffMs: Int, val active: Boolean) : HostToGlasses
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */ /** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
@Serializable @Serializable
@SerialName("stt_phrase") @SerialName("stt_phrase")
@@ -3,6 +3,8 @@ package pw.binom.viewmate.core.protocol
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.viewmate.core.log.LogEntry
/** /**
* Сообщения от очков к хосту (телефону). * Сообщения от очков к хосту (телефону).
* Полиморфная сериализация: дискриминатор "type" со значением @SerialName. * Полиморфная сериализация: дискриминатор "type" со значением @SerialName.
@@ -70,6 +72,10 @@ data class PlaybackPosition(
val playing: Boolean, val playing: Boolean,
val itemId: String? = null, val itemId: String? = null,
val audioIndex: Int = 0, val audioIndex: Int = 0,
/** Время отправки на часах очков (epoch ms). Телефон мерит латентность линка = now − sentTs. 0 = не замерено. */
val sentTs: Long = 0L,
/** Длительность видео на очках, мс; -1 = неизвестно (стара прошивка / ещё не prepared). */
val durationMs: Long = -1L,
) : GlassesToHost ) : GlassesToHost
/** Очки выключились / экран погас. */ /** Очки выключились / экран погас. */
@@ -102,3 +108,22 @@ data class SttAudio(val data: ByteArray) : GlassesToHost {
@Serializable @Serializable
@SerialName("stop_stt") @SerialName("stop_stt")
data class StopStt(val cancel: Boolean) : GlassesToHost data class StopStt(val cancel: Boolean) : GlassesToHost
/**
* Готовый распознанный текст (очки → телефон): очки сами распознали речь локально
* (Qwen3-ASR) и шлют фактический текст — телефон маршрутизирует его в LLM.
* Замена пути SttAudio/StopStt (там распознавал телефон); тот остаётся как фолбэк
* для старых очей. Пустой текст очки не шлют.
*/
@Serializable
@SerialName("stt_text")
data class SttText(val text: String) : GlassesToHost
/**
* Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему
* коллектору и пересылает на сервер вместе со своими лог-записями;
* источник — [LogEntry.source]. Старые очки такое сообщение не шлют.
*/
@Serializable
@SerialName("log_batch")
data class LogBatchMsg(val entries: List<LogEntry>) : GlassesToHost
@@ -0,0 +1,171 @@
package pw.binom.viewmate.core.stt
import kotlin.math.sqrt
/**
* Чистые (без Android) преобразования PCM перед офлайн-распознаванием — общие
* для телефона и очков. Телефон накапливает и распознаёт на себе (фолбэк); очки
* распозновывают локально и шлют готовый текст (новый путь). Тело — дословно из
* SttStreamer (app-phone): числа выверены на дампах микрофона очков (×3, 16 кГц),
* НЕ менять без перепроверки.
*/
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
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
}
/**
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
*
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
* Чистая функция (без Android) — покрыта JVM-тестами.
*/
fun trimEdges(
samples: FloatArray,
threshold: Float = 0.01f,
windowSize: Int = 320,
padWindows: Int = 15,
): FloatArray {
if (samples.isEmpty()) return samples
var first = -1
var lastEnd = -1
var idx = 0
while (idx < samples.size) {
val end = minOf(idx + windowSize, samples.size)
var sum = 0.0
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
val rms = sqrt(sum / (end - idx)).toFloat()
if (rms >= threshold) {
if (first == -1) first = idx
lastEnd = end
}
idx = end
}
if (first == -1) return FloatArray(0)
val pad = padWindows * windowSize
val start = maxOf(0, first - pad)
val stop = minOf(samples.size, lastEnd + pad)
return samples.copyOfRange(start, stop)
}
/**
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
* НЕ менять без перепроверки на тех же дампах.
*
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
* каждой стороны (clamp к границам массива). Фраза короче
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
* &lt; 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
*/
fun splitPhrases(
samples: FloatArray,
threshold: Float = 0.005f,
windowSize: Int = 320, // 20 мс @ 16 кГц
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
): List<FloatArray> {
if (samples.isEmpty()) return emptyList()
val winCount = (samples.size + windowSize - 1) / windowSize
val isSound = BooleanArray(winCount)
var anySound = false
for (w in 0 until winCount) {
val s = w * windowSize
val e = minOf(s + windowSize, samples.size)
var sum = 0.0
for (k in s until e) sum += samples[k] * samples[k].toDouble()
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
isSound[w] = true
anySound = true
}
}
if (!anySound) return emptyList()
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
class Phrase(var start: Int, var end: Int)
val phrases = mutableListOf<Phrase>()
var pos = 0
while (pos < winCount) {
var sil = 0
while (pos + sil < winCount && !isSound[pos + sil]) sil++
if (pos + sil >= winCount) break
val start = pos + sil
var end = start
var continuePhrase = true
while (continuePhrase) {
var run = 0
while (end + run < winCount && isSound[end + run]) run++
end += run
if (end >= winCount) { continuePhrase = false; break }
var sil2 = 0
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
if (sil2 >= minSilenceWindows) {
continuePhrase = false
} else {
end += sil2
}
}
phrases.add(Phrase(start, end))
pos = end
}
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
while (phrases.size > 1) {
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
if (idx < 0) break
val other = if (idx == 0) idx + 1 else idx - 1
val lo = minOf(idx, other)
val hi = maxOf(idx, other)
val merged = Phrase(
minOf(phrases[lo].start, phrases[hi].start),
maxOf(phrases[lo].end, phrases[hi].end),
)
phrases.removeAt(hi)
phrases.removeAt(lo)
phrases.add(lo, merged)
}
val pad = padWindows * windowSize
val out = mutableListOf<FloatArray>()
for (p in phrases) {
val s = maxOf(0, p.start * windowSize - pad)
val e = minOf(samples.size, p.end * windowSize + pad)
if (e - s <= maxPhraseSamples) {
out += samples.copyOfRange(s, e)
} else {
val chunk = (maxPhraseSamples / windowSize) * windowSize
var cs = s
while (cs < e) {
val ce = minOf(e, cs + chunk)
out += samples.copyOfRange(cs, ce)
cs = ce
}
}
}
return out
}
@@ -0,0 +1,175 @@
package pw.binom.viewmate.core.log
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
class BatchingLogCollectorTest {
/** Фейк «следующего» коллектора: фиксирует пачки, [fail] — имитация сбоя. */
class FakeSink(var fail: Boolean = false) : LogSink {
val sent = mutableListOf<LogBatch>()
override suspend fun send(batch: LogBatch): Boolean {
if (fail) return false
sent += batch
return true
}
}
@Test
fun `timer flush sends buffered entries`() = runTest {
val sink = FakeSink()
val collector = BatchingLogCollector(
device = "glasses",
sink = sink,
spool = InMemoryLogSpool(),
clock = FixedClock(1000),
flushIntervalMs = 100,
maxBatchEntries = 10,
)
@OptIn(ExperimentalCoroutinesApi::class)
val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler))
collector.start(scope)
collector.record("tag1", "hello")
collector.record("tag2", "world")
testScheduler.advanceTimeBy(150)
val batch = sink.sent.single()
assertEquals(2, batch.entries.size)
assertEquals("glasses", batch.device)
assertEquals(listOf("tag1", "tag2"), batch.entries.map { it.tag })
assertEquals("glasses", batch.entries[0].source)
scope.cancel()
}
@Test
fun `failed sink spools batch, later flush retries`() = runTest {
val sink = FakeSink(fail = true)
val spool = InMemoryLogSpool()
val collector = BatchingLogCollector("phone", sink, spool, FixedClock())
collector.record("a", "1")
collector.flush()
assertEquals(0, sink.sent.size)
assertEquals(1, spool.pendingCount())
sink.fail = false
collector.flush() // ретраи спула
assertEquals(1, sink.sent.size)
assertEquals("a", sink.sent[0].entries.single().tag)
assertEquals(0, spool.pendingCount())
}
@Test
fun `spool backlog blocks new batches, FIFO order kept`() = runTest {
val sink = FakeSink(fail = true)
val spool = InMemoryLogSpool()
val collector = BatchingLogCollector("phone", sink, spool, FixedClock())
collector.record("a", "1")
collector.flush() // batch1 → спул
collector.record("b", "2")
collector.flush() // спул затык — batch2 остаётся в буфере
assertEquals(1, spool.pendingCount())
sink.fail = false
collector.flush() // доразгрузили спул (batch1)
collector.flush() // теперь batch2
assertEquals(2, sink.sent.size)
assertEquals(listOf("a", "b"), sink.sent.flatMap { it.entries }.map { it.tag })
assertEquals(0, spool.pendingCount())
}
@Test
fun `foreign entries keep their source and ts`() = runTest {
val sink = FakeSink()
val collector = BatchingLogCollector("phone", sink, InMemoryLogSpool(), FixedClock(999))
collector.recordBatch(listOf(LogEntry(42, "mc", "glasses event", "glasses")))
collector.record("phone", "own")
collector.flush()
val batch = sink.sent.single()
assertEquals("phone", batch.device)
assertEquals(2, batch.entries.size)
assertEquals("glasses", batch.entries[0].source)
assertEquals(42, batch.entries[0].ts)
assertEquals("phone", batch.entries[1].source)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `overflowing batch flushes immediately`() = runTest {
val sink = FakeSink()
val collector = BatchingLogCollector(
device = "glasses",
sink = sink,
spool = InMemoryLogSpool(),
clock = FixedClock(),
flushIntervalMs = 60_000,
maxBatchEntries = 2,
)
val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler))
collector.start(scope)
collector.record("a", "1")
collector.record("b", "2") // буфер == лимит → немедленный flush
testScheduler.advanceTimeBy(0)
val batch = sink.sent.single()
assertEquals(2, batch.entries.size)
scope.cancel()
}
@Test
fun `empty recordBatch is no-op`() = runTest {
val sink = FakeSink()
val collector = BatchingLogCollector("phone", sink, InMemoryLogSpool(), FixedClock())
collector.recordBatch(emptyList())
collector.flush()
assertNull(sink.sent.firstOrNull())
}
@Test
fun `out-of-range spooled entries are dropped, buffer still flushed`() = runTest {
val sink = FakeSink()
val spool = InMemoryLogSpool()
val now = 1_000_000_000_000L
val collector = BatchingLogCollector("phone", sink, spool, FixedClock(now))
// «старые» пачки в спуле (метки из прошлого, как при загрузке без NTP)
spool.write(LogBatch("phone", listOf(LogEntry(0, "old", "stale", "phone"))))
spool.write(LogBatch("phone", listOf(LogEntry(123, "older", "stale2", "phone"))))
collector.record("fresh", "current") // ts = now → валидно
collector.flush()
// старые пачки выкинуты (не топят пуш), свежая ушла в sink
assertEquals(0, spool.pendingCount())
assertEquals(1, sink.sent.size)
assertEquals(listOf("fresh"), sink.sent[0].entries.map { it.tag })
}
@Test
fun `valid spooled batch retried and sent before buffer`() = runTest {
val sink = FakeSink(fail = true)
val spool = InMemoryLogSpool()
val collector = BatchingLogCollector("phone", sink, spool, FixedClock(1_000_000_000_000L))
collector.record("a", "1")
collector.flush() // → спул (fail)
sink.fail = false
collector.record("b", "2")
collector.flush()
assertEquals(2, sink.sent.size)
assertEquals(listOf("a", "b"), sink.sent.flatMap { it.entries }.map { it.tag })
assertEquals(0, spool.pendingCount())
}
}
@@ -0,0 +1,50 @@
package pw.binom.viewmate.core.log
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class LogSpoolTest {
private fun batch(tag: String) = LogBatch("phone", listOf(LogEntry(0, tag, tag, "phone")))
@Test
fun `in-memory spool is FIFO with cap dropping oldest`() {
val spool = InMemoryLogSpool(maxBatches = 2)
spool.write(batch("1"))
spool.write(batch("2"))
spool.write(batch("3")) // «1» выброшена
assertEquals(2, spool.pendingCount())
assertEquals("2", spool.peek()?.entries?.single()?.tag)
assertEquals("2", spool.take()?.entries?.single()?.tag)
assertEquals("3", spool.take()?.entries?.single()?.tag)
assertNull(spool.take())
assertEquals(0, spool.pendingCount())
}
@Test
fun `in-memory spool peek does not consume`() {
val spool = InMemoryLogSpool()
spool.write(batch("a"))
assertEquals("a", spool.peek()?.entries?.single()?.tag)
assertEquals("a", spool.peek()?.entries?.single()?.tag)
assertEquals(1, spool.pendingCount())
}
@Test
fun `batch json roundtrip`() {
val batch = LogBatch(
"phone",
listOf(LogEntry(1, "a", "x", "phone"), LogEntry(2, "b", "y", "glasses")),
)
val text = LogBatch.encodeBatch(batch)
assertEquals(batch, LogBatch.decodeBatch(text))
}
@Test
fun `batch json decode garbage is null`() {
assertNull(LogBatch.decodeBatch("garbage"))
assertNull(LogBatch.decodeBatch(""))
}
}
@@ -0,0 +1,40 @@
package pw.binom.viewmate.core.log
import kotlin.test.Test
import kotlin.test.assertEquals
class LokiLogSinkTest {
@Test
fun `payload groups by source and labels instance`() {
val entries = listOf(
LogEntry(1_000, "hub", "phone line", "phone"),
LogEntry(2_000, "mc", "glasses line", "glasses"),
LogEntry(3_000, "hub", "another phone", "phone"),
)
val push = buildLokiPush(entries, "view-mate")
assertEquals(2, push.streams.size)
val phoneStream = push.streams.first { it.stream["instance"] == "phone" }
val glassesStream = push.streams.first { it.stream["instance"] == "glasses" }
assertEquals("view-mate", phoneStream.stream["job"])
assertEquals(2, phoneStream.values.size)
// timestamp: ms * 1e6, sorted ascending
assertEquals("1000000000", phoneStream.values[0][0])
assertEquals("hub: phone line", phoneStream.values[0][1])
assertEquals("3000000000", phoneStream.values[1][0])
assertEquals(1, glassesStream.values.size)
assertEquals("2000000000", glassesStream.values[0][0])
assertEquals("mc: glasses line", glassesStream.values[0][1])
}
@Test
fun `blank source falls back to phone`() {
val push = buildLokiPush(listOf(LogEntry(0, "t", "m", "")), "job")
assertEquals(1, push.streams.size)
assertEquals("phone", push.streams[0].stream["instance"])
}
}
@@ -125,6 +125,7 @@ class GlassesToHostTest {
playing = true, playing = true,
itemId = "movie-1", itemId = "movie-1",
audioIndex = 2, audioIndex = 2,
durationMs = 7_200_000,
) )
val text = json.encodeToString(GlassesToHost.serializer(), msg) val text = json.encodeToString(GlassesToHost.serializer(), msg)
assertTrue("\"itemId\":\"movie-1\"" in text) assertTrue("\"itemId\":\"movie-1\"" in text)
@@ -135,6 +136,7 @@ class GlassesToHostTest {
assertTrue(decoded.playing) assertTrue(decoded.playing)
assertEquals("movie-1", decoded.itemId) assertEquals("movie-1", decoded.itemId)
assertEquals(2, decoded.audioIndex) assertEquals(2, decoded.audioIndex)
assertEquals(7_200_000, decoded.durationMs)
} }
@Test @Test
@@ -147,6 +149,7 @@ class GlassesToHostTest {
assertTrue(!decoded.playing) assertTrue(!decoded.playing)
assertNull(decoded.itemId) assertNull(decoded.itemId)
assertEquals(0, decoded.audioIndex) assertEquals(0, decoded.audioIndex)
assertEquals(-1L, decoded.durationMs)
} }
@Test @Test
@@ -210,4 +213,14 @@ class GlassesToHostTest {
assertIs<StopStt>(decoded) assertIs<StopStt>(decoded)
assertTrue(decoded.cancel) assertTrue(decoded.cancel)
} }
@Test
fun sttTextRoundTrip() {
val msg: GlassesToHost = SttText(text = "включи сериал брат 2")
val text = json.encodeToString(GlassesToHost.serializer(), msg)
assertEquals("""{"type":"stt_text","text":"включи сериал брат 2"}""", text)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<SttText>(decoded)
assertEquals("включи сериал брат 2", decoded.text)
}
} }
@@ -265,4 +265,15 @@ class HostToGlassesTest {
assertEquals("привет", decoded.messages[0].content) assertEquals("привет", decoded.messages[0].content)
assertEquals("assistant", decoded.messages[1].role) assertEquals("assistant", decoded.messages[1].role)
} }
@Test
fun audioSyncInfoRoundTrip() {
val msg: HostToGlasses = AudioSyncInfo(diffMs = 340, active = true)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"audio_sync_info","diffMs":340,"active":true}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<AudioSyncInfo>(decoded)
assertEquals(340, decoded.diffMs)
assertTrue(decoded.active)
}
} }
@@ -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()
}
@@ -0,0 +1,66 @@
package pw.binom.viewmate.core.log
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class FileLogSpoolTest {
private var tempDir: File? = null
private fun batch(tag: String) = LogBatch("phone", listOf(LogEntry(0, tag, tag, "phone")))
private fun freshDir(name: String): File {
val dir = Files.createTempDirectory("logspool-$name").toFile()
tempDir = dir
return dir
}
@AfterTest
fun cleanup() {
tempDir?.deleteRecursively()
}
@Test
fun `writes and drains FIFO, survives restart, trims cap`() {
val dir = freshDir("fif")
var spool = FileLogSpool(dir.absolutePath, maxFiles = 2)
spool.write(batch("1"))
spool.write(batch("2"))
spool.write(batch("3")) // «1» выбрасывается по потолку
assertEquals(2, spool.pendingCount())
assertEquals("2", spool.peek()?.entries?.single()?.tag)
assertEquals("2", spool.take()?.entries?.single()?.tag)
// «перезапуск»: новая инстанция видит прихранианные файлы и продолжает сериал
spool = FileLogSpool(dir.absolutePath, maxFiles = 2)
assertEquals(1, spool.pendingCount())
assertEquals("3", spool.take()?.entries?.single()?.tag)
assertNull(spool.take())
}
@Test
fun `corrupt file is skipped without blocking queue`() {
val dir = freshDir("corrupt")
File(dir, "logspool-1.json").writeText("not a json")
val spool = FileLogSpool(dir.absolutePath, 64)
spool.write(batch("a"))
assertEquals("a", spool.take()?.entries?.single()?.tag) // битый удалён, «a» дошёл
assertNull(spool.take())
assertEquals(0, spool.pendingCount())
}
@Test
fun `empty directory is empty spool`() {
val dir = freshDir("empty")
val spool = FileLogSpool(dir.absolutePath, 64)
assertEquals(0, spool.pendingCount())
assertNull(spool.peek())
assertNull(spool.take())
}
}