Compare commits
37 Commits
8958370507
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a500150d | |||
| f68ea10342 | |||
| 5c86ed00b4 | |||
| 0b3f9ff975 | |||
| 40723fa584 | |||
| 728116662e | |||
| 0cfd92a68f | |||
| f656f43e10 | |||
| 1b28760c6e | |||
| b2e2d2697d | |||
| 92b483a5e3 | |||
| edf7cf63f6 | |||
| d5e60a2565 | |||
| 4587e22b50 | |||
| e18b82642f | |||
| b11879520e | |||
| 3af74851db | |||
| 1aa389a3a0 | |||
| 6cd4a8de43 | |||
| 62d2f06797 | |||
| cf8c5ef604 | |||
| 236ff18543 | |||
| 412e093174 | |||
| da8182443c | |||
| 17385d1784 | |||
| 98ac84ffe8 | |||
| ac56e70283 | |||
| 4e60e11d24 | |||
| b0baa19e7d | |||
| d34d15ecef | |||
| 19fe1b34bc | |||
| 1009713a28 | |||
| 0f235da0ed | |||
| 74701a2108 | |||
| 4f4a551c91 | |||
| 80236902ba | |||
| d113ffd744 |
@@ -0,0 +1,119 @@
|
|||||||
|
# TASK: сегментация STT-буфера на фразы (whisper ≤30с)
|
||||||
|
|
||||||
|
## Проблема (доказана на железе 25.08, полевым тестом ac56e70)
|
||||||
|
|
||||||
|
sherpa-onnx OfflineRecognizer (whisper-small int8) принимает волны ТОЛЬКО ≤ 30 с:
|
||||||
|
лог движка «Only waves less than 30 seconds are supported. We process only the
|
||||||
|
first 30 seconds and discard the remaining data». При слитном буфере сессии
|
||||||
|
(полевой тест: 117.6 с после trimEdges) whisper обработал первые 30 с (тишина)
|
||||||
|
и выдал галлюцинации («Убираем. Убираем…», «Хуйню ты на распознавал»), а вся
|
||||||
|
речь после 30 с молча выброшена. Проверено на хосте тем же движком
|
||||||
|
(sherpa-onnx 1.13.6): тот же raw, нарезанный скользящим окном 30 с (шаг 15 с),
|
||||||
|
распознаётся чисто: «Я говорю что ты умеешь? Какие тебе доступны тулы? Ну
|
||||||
|
давай, а что из этого тулсета у тебя есть? Раз, два, три, четыре, пять».
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Перед распознаванием резать буфер на фразы по паузам тишины (≥ 0.6 с),
|
||||||
|
распознавать каждую фразу отдельно (гарантированно ≤ 30 с), склеивать
|
||||||
|
результаты через ПРОБЕЛ (решение пользователя, дословно: «пробелами надо
|
||||||
|
разделять»).
|
||||||
|
|
||||||
|
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
|
||||||
|
|
||||||
|
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt`
|
||||||
|
— новая чистая функция `splitPhrases` + правка `recognizeFullBufferLocked`.
|
||||||
|
2. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt`
|
||||||
|
— НОВЫЙ файл, JVM-тесты `splitPhrases`.
|
||||||
|
3. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt`
|
||||||
|
— добавить ОДИН кейс «две фразы через паузу → распознаны раздельно, склеены
|
||||||
|
пробелом» (переиспользовать приватный `RecordingRecognizer` того же класса).
|
||||||
|
|
||||||
|
ЗАПРЕЩЕНО трогать: `SttMicStream.kt`, `WhisperStt.kt`, `trimEdges` (кроме
|
||||||
|
вызова), `GlassesServer.kt`, `PhoneApp.kt`, `lib-core`, протокол, весь
|
||||||
|
app-glasses. Все scratch-файлы — внутри репо, в коммит не включать.
|
||||||
|
|
||||||
|
## 1. splitPhrases — чистая internal функция в SttStreamer.kt
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/**
|
||||||
|
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||||
|
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||||
|
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||||
|
* НЕ менять без перепроверки на тех же дампах.
|
||||||
|
*/
|
||||||
|
internal fun splitPhrases(
|
||||||
|
samples: FloatArray,
|
||||||
|
threshold: Float = 0.005f,
|
||||||
|
windowSize: Int = 320, // 20 мс @ 16 кГц
|
||||||
|
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
||||||
|
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
||||||
|
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
||||||
|
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
||||||
|
): List<FloatArray>
|
||||||
|
```
|
||||||
|
|
||||||
|
Семантика (точная, из полевого разбора):
|
||||||
|
|
||||||
|
- Окно [windowSize] сэмплов — ТИШИНА, если RMS < threshold.
|
||||||
|
Порог 0.005, НЕ 0.01 как в trimEdges: 0.01 режет тихие начала слов на
|
||||||
|
обрубки, whisper на обрубке выдаёт мусор. На усиленном ×3 потоке шум
|
||||||
|
~0.005–0.009, речь ~0.03–0.14.
|
||||||
|
- Пауза = подряд ≥ minSilenceWindows тихих окон (0.6 с). Пауза РАЗДЕЛЯЕТ фразы.
|
||||||
|
- Фраза = звуковые окна между паузами + отступ padWindows тихих окон с каждой
|
||||||
|
стороны (clamp к границам массива, как в trimEdges).
|
||||||
|
- Фраза короче minPhraseWindows (1.5 с): объединить с СОСЕДНЕЙ фразой (короткий
|
||||||
|
сегмент = обрубок; whisper на нём мусорит). Единственная фраза — оставить
|
||||||
|
как есть.
|
||||||
|
- Фраза длиннее maxPhraseSamples (30 с): резать жёстко на куски ≤ 30 с по
|
||||||
|
границе окна (sherpa-onnx молча обрезает длинную волну — страховка обязательна).
|
||||||
|
- Пустой вход / всё тишина → пустой список.
|
||||||
|
- Внутренние паузы < 0.6 с НЕ трогать (внутри фразы тишина остаётся: склейка
|
||||||
|
пауз ломает whisper — «де-тишина» давала «1,2,3,4,5,8,10», проверено).
|
||||||
|
|
||||||
|
## 2. recognizeFullBufferLocked — правка
|
||||||
|
|
||||||
|
Текущий код (строки ~187–211 SttStreamer.kt): `trimEdges(base)` → ОДИН вызов
|
||||||
|
`stt.recognize(chunk)`.
|
||||||
|
|
||||||
|
Новый поток:
|
||||||
|
|
||||||
|
1. `val chunk = trimEdges(base)` — как сейчас, НЕ менять.
|
||||||
|
2. `val phrases = splitPhrases(chunk)`.
|
||||||
|
3. `phrases` пуст → `return` (как сейчас при пустом chunk).
|
||||||
|
4. В executor-задаче: для КАЖДОЙ фразы последовательно `stt.recognize(phrase)`;
|
||||||
|
результат с `isBlank()` — пропускать (мусорный сегмент, в текст не вставлять);
|
||||||
|
итог — `joinToString(" ")` по непустым результатам.
|
||||||
|
5. Все фразы пустые → `full` НЕ перезаписывать (семантика finish без текста
|
||||||
|
сохраняется, вызывающий сам решит).
|
||||||
|
6. Лог: `полный буфер: N фраз (2.3с, 4.1с, …) → '$full'` — размеры фраз в
|
||||||
|
секундах, один знак после запятой.
|
||||||
|
7. `task.get(...)`: таймаут поднять с 30 до 60 секунд (N фраз × до 30 с каждая).
|
||||||
|
|
||||||
|
## 3. Тесты
|
||||||
|
|
||||||
|
PhraseSplitTest.kt — чистые JVM-тесты (синтез сигнала в стиле FullBufferSttTest:
|
||||||
|
зубчатая волна как «речь», нули как тишина; для «тихих начал» — плавное
|
||||||
|
нарастание амплитуды):
|
||||||
|
|
||||||
|
1. Две фразы (по ~2 с), пауза 0.7 с → ровно 2 элемента; обе начинаются с
|
||||||
|
звучного окна (не с тишины).
|
||||||
|
2. Пауза 0.4 с (< 0.6) НЕ разделяет → 1 элемент.
|
||||||
|
3. Тихие начала слов не режутся: фраза с окнами 0.003–0.009 на старте остаётся
|
||||||
|
целой (порог 0.005) → 1 элемент, начало фразы включает тихие окна.
|
||||||
|
4. Всё тишина → пустой список.
|
||||||
|
5. Пустой вход → пустой список.
|
||||||
|
6. Фраза 31 с → разрезана на 2 куска, каждый ≤ 30 с.
|
||||||
|
7. Две короткие фразы (0.8 с и 0.9 с, пауза 0.7 с) → объединены в одну.
|
||||||
|
|
||||||
|
FullBufferSttTest.kt — добавить кейс (внутри класса, до закрывающей `}`):
|
||||||
|
|
||||||
|
- буфер: «фраза А» (1.5 с зуба), пауза 0.7 с нулей, «фраза Б» (1.5 с зуба);
|
||||||
|
`RecordingRecognizer("А", "Б")`; после `finish()`: `calls.size == 2`,
|
||||||
|
`full == "А Б"` (пробел — разделитель по решению пользователя).
|
||||||
|
|
||||||
|
## Проверка (делает Hermes, не агент)
|
||||||
|
|
||||||
|
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
|
||||||
|
число тестов app-phone выросло на 8 (7 новых + 1 дополненный).
|
||||||
|
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# TASK: замена whisper-small на Qwen3-ASR-0.6B int8 (sherpa-onnx, CPU)
|
||||||
|
|
||||||
|
## Проблема (доказана на железе 25.08, полевым тестом ac56e70 + серверным экспериментом)
|
||||||
|
|
||||||
|
whisper-small int8 распознаёт русскую речь посредственно: «Какие тебе доступные
|
||||||
|
толулы?», «Ну давай, а что из этого туалета у тебя есть?» (было на дампах
|
||||||
|
пользователя). Qwen3-ASR-0.6B (тот же движок sherpa-onnx 1.13.6, тот же CPU)
|
||||||
|
без подсказок — ровня висперу, а с hotwords («тулсет,тулы,умеешь») исправляет
|
||||||
|
все провалы в ноль: «Какие тебе доступные тулы?», «Ну давай, а что из этого
|
||||||
|
тулсета у тебя есть?», «Что ты умеешь?». Эксперимент проведён на дампах
|
||||||
|
пользователя тем же движком и той же моделью (int8), скорость на серверном CPU
|
||||||
|
0.1-0.2 от реального времени (фраза 4.4 с → 0.9 с). Решение пользователя:
|
||||||
|
остаёмся на CPU (аппаратное ускорение NNAPI для трансформеров не даёт выигрыша).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Новый класс `Qwen3AsrStt` (реализует тот же интерфейс `PhraseRecognizer`, что и
|
||||||
|
`WhisperStt`) + переключатель-константа в фабрике `PhoneApp.ensureStt()`.
|
||||||
|
Старый `WhisperStt` НЕ удалять — откат в одну строку. Сегментация по паузам
|
||||||
|
(`splitPhrases` + склейка пробелом в `SttStreamer`) НЕ меняется — Qwen3-ASR тоже
|
||||||
|
имеет лимит max_total_len=512 токенов (≈ 30-40 с аудио), нарезка остаётся в силе.
|
||||||
|
|
||||||
|
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
|
||||||
|
|
||||||
|
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt` — НОВЫЙ файл.
|
||||||
|
2. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt` — только
|
||||||
|
`ensureStt()` (строки ~350-366) + 2 константы в companion object (строка 512).
|
||||||
|
|
||||||
|
ЗАПРЕЩЕНО трогать: `WhisperStt.kt`, `SttStreamer.kt`, `SttDebug.kt`,
|
||||||
|
`GlassesServer.kt`, `lib-core`, протокол, весь app-glasses. Все scratch-файлы —
|
||||||
|
вне репо или в коммит не включать.
|
||||||
|
|
||||||
|
## 1. Qwen3AsrStt.kt — новый класс
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineModelConfig
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineRecognizer
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
|
||||||
|
|
||||||
|
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
|
||||||
|
class Qwen3AsrStt(
|
||||||
|
private val convFrontendPath: String,
|
||||||
|
private val encoderPath: String,
|
||||||
|
private val decoderPath: String,
|
||||||
|
private val tokenizerDir: String,
|
||||||
|
private val numThreads: Int = 4,
|
||||||
|
private val hotwords: String = "",
|
||||||
|
) : PhraseRecognizer {
|
||||||
|
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||||
|
null,
|
||||||
|
OfflineRecognizerConfig(
|
||||||
|
modelConfig = OfflineModelConfig(
|
||||||
|
qwen3Asr = OfflineQwen3AsrModelConfig(
|
||||||
|
convFrontend = convFrontendPath,
|
||||||
|
encoder = encoderPath,
|
||||||
|
decoder = decoderPath,
|
||||||
|
tokenizer = tokenizerDir,
|
||||||
|
maxTotalLen = 512,
|
||||||
|
maxNewTokens = 128,
|
||||||
|
temperature = 1e-6f,
|
||||||
|
topP = 0.8f,
|
||||||
|
seed = 42,
|
||||||
|
hotwords = hotwords,
|
||||||
|
),
|
||||||
|
numThreads = numThreads,
|
||||||
|
provider = "cpu",
|
||||||
|
modelType = "qwen3_asr",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||||
|
override fun recognize(samples: FloatArray): String {
|
||||||
|
val stream = recognizer.createStream()
|
||||||
|
try {
|
||||||
|
stream.acceptWaveform(samples, 16000)
|
||||||
|
recognizer.decode(stream)
|
||||||
|
return recognizer.getResult(stream).text.trim()
|
||||||
|
} finally {
|
||||||
|
stream.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Точные имена полей и сигнатуры подтверждены байткодом sherpa-onnx v1.13.6
|
||||||
|
(`OfflineQwen3AsrModelConfig` — поля convFrontend/encoder/decoder/tokenizer/
|
||||||
|
maxTotalLen/maxNewTokens/temperature/topP/seed/hotwords; `OfflineModelConfig` —
|
||||||
|
поле qwen3Asr; нативная строка `qwen3_asr=` подтверждает modelType).
|
||||||
|
Дефолты декодирования — из C++-конфига sherpa-onnx 1.13.6: max_total_len=512,
|
||||||
|
max_new_tokens=128, temperature=1e-6, top_p=0.8, seed=42. Поле `tokens` в
|
||||||
|
OfflineModelConfig для qwen3_asr НЕ передавать (токенизатор — директория в
|
||||||
|
qwen3Asr.tokenizer, не файл).
|
||||||
|
|
||||||
|
## 2. PhoneApp.kt — фабрика и константы
|
||||||
|
|
||||||
|
В companion object (строка 512) добавить:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
|
||||||
|
private const val STT_BACKEND = "qwen3_asr"
|
||||||
|
|
||||||
|
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||||
|
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||||
|
```
|
||||||
|
|
||||||
|
В `ensureStt()` (строки ~350-366) — заменить блок создания whisper на:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val qwenConv = File(modelsDir, "conv_frontend.onnx")
|
||||||
|
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
|
||||||
|
val qwenDec = File(modelsDir, "decoder.int8.onnx")
|
||||||
|
val qwenTok = File(modelsDir, "tokenizer")
|
||||||
|
if (STT_BACKEND == "qwen3_asr" &&
|
||||||
|
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
|
||||||
|
) {
|
||||||
|
stt = Qwen3AsrStt(
|
||||||
|
convFrontendPath = qwenConv.absolutePath,
|
||||||
|
encoderPath = qwenEnc.absolutePath,
|
||||||
|
decoderPath = qwenDec.absolutePath,
|
||||||
|
tokenizerDir = qwenTok.absolutePath,
|
||||||
|
numThreads = 4,
|
||||||
|
hotwords = STT_HOTWORDS,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||||
|
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
||||||
|
val tokens = File(modelsDir, "small-tokens.txt")
|
||||||
|
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
||||||
|
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
stt = WhisperStt(
|
||||||
|
encoderPath = encoder.absolutePath,
|
||||||
|
decoderPath = decoder.absolutePath,
|
||||||
|
tokensPath = tokens.absolutePath,
|
||||||
|
numThreads = 4,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Тип `stt` — `PhraseRecognizer` (локальная переменная в synchronized-блоке;
|
||||||
|
дальше `SttStreamer(stt = stt, ...)` как сейчас). Лог создания: добавить строку
|
||||||
|
`log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")` перед созданием
|
||||||
|
SttStreamer. Старая ветка whisper остаётся для отката.
|
||||||
|
|
||||||
|
## Проверка (делает Hermes, не агент)
|
||||||
|
|
||||||
|
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
|
||||||
|
число тестов app-phone НЕ меняется (новый класс не покрывается JVM-тестами —
|
||||||
|
Android + нативная библиотека; splitPhrases/trimEdges не трогаются).
|
||||||
|
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
|
||||||
|
- Дальше Hermes сам: adb push моделей (conv_frontend.onnx, encoder.int8.onnx,
|
||||||
|
decoder.int8.onnx, tokenizer/ — из /root/viewmate/models/sherpa-onnx-qwen3-asr-0.6B-int8/)
|
||||||
|
на телефон в filesDir/models + установка APK + полевая приёмка на железе.
|
||||||
@@ -0,0 +1,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**, в стиле проекта (см. соседние файлы). Без лишних зависимостей.
|
||||||
|
- НЕ коммитить сами — собрать и прогнать тесты, показать диф. (Коммит — после подтверждения здоров: образец ты решаешь/проверяешь сам.)
|
||||||
@@ -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,8 +98,15 @@ 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 автора)
|
||||||
implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
|
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
|
||||||
|
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
|
||||||
|
// implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
|
||||||
|
|
||||||
// WS-клиент очков (OkHttp — Android-движок)
|
// WS-клиент очков (OkHttp — Android-движок)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ 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.stt.GlassesAsr
|
||||||
|
import pw.binom.viewmate.glasses.stt.GlassesSttSession
|
||||||
|
import pw.binom.viewmate.glasses.stt.SttModelDownloader
|
||||||
|
|
||||||
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||||
data class ChatEntry(val role: String, val text: String)
|
data class ChatEntry(val role: String, val text: String)
|
||||||
@@ -45,6 +54,26 @@ class GlassesApp : Application() {
|
|||||||
lateinit var downloadManager: GlassesDownloadManager
|
lateinit var downloadManager: GlassesDownloadManager
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */
|
||||||
|
lateinit var sttModel: SttModelDownloader
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */
|
||||||
|
lateinit var asr: GlassesAsr
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */
|
||||||
|
lateinit var sttSession: GlassesSttSession
|
||||||
|
private set
|
||||||
|
|
||||||
|
|
||||||
|
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
||||||
|
lateinit var logCollector: BatchingLogCollector
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Скоуп фоновых корутин (таймер лог-коллектора). */
|
||||||
|
private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
private val prefs by lazy {
|
private val prefs by lazy {
|
||||||
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
|
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
@@ -55,7 +84,11 @@ 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/тишина завершили). */
|
/**
|
||||||
|
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click,
|
||||||
|
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
|
||||||
|
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
|
||||||
|
*/
|
||||||
val listening = MutableStateFlow(false)
|
val listening = MutableStateFlow(false)
|
||||||
|
|
||||||
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
|
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
|
||||||
@@ -85,6 +118,13 @@ class GlassesApp : Application() {
|
|||||||
*/
|
*/
|
||||||
val answerScrollDelta = MutableStateFlow(0f)
|
val answerScrollDelta = MutableStateFlow(0f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
||||||
|
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
|
||||||
|
* (видео на очках не играет / данных ещё нет).
|
||||||
|
*/
|
||||||
|
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
|
||||||
|
|
||||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||||
var mainActivity: MainActivity? = null
|
var mainActivity: MainActivity? = null
|
||||||
|
|
||||||
@@ -119,6 +159,35 @@ class GlassesApp : Application() {
|
|||||||
)
|
)
|
||||||
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)")
|
||||||
|
|
||||||
|
// Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range).
|
||||||
|
sttModel = SttModelDownloader(File(filesDir, "models"), bgScope)
|
||||||
|
sttModel.ensure()
|
||||||
|
|
||||||
|
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
|
||||||
|
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity.
|
||||||
|
asr = GlassesAsr(File(filesDir, "models"))
|
||||||
|
sttSession = GlassesSttSession(
|
||||||
|
filesDir = filesDir,
|
||||||
|
scope = bgScope,
|
||||||
|
recognizer = { asr.recognize(it) },
|
||||||
|
modelReady = { sttModel.status.value.ready },
|
||||||
|
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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
|
||||||
@@ -101,6 +102,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 +249,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,6 +340,16 @@ 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 -> {
|
||||||
@@ -368,12 +381,12 @@ class HostConnection(
|
|||||||
is SttCancel -> {
|
is SttCancel -> {
|
||||||
when (msg.reason) {
|
when (msg.reason) {
|
||||||
SttCancel.REASON_CANCEL -> {
|
SttCancel.REASON_CANCEL -> {
|
||||||
// Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить
|
// Явная отмена диктовки двойным тапом: очки САМИ уже вернулись
|
||||||
// listening/thinking, НО остаться в режиме (показать последний ответ),
|
// в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат,
|
||||||
// микрофон заново НЕ открывать, из режима НЕ выходить.
|
// оверлей остаётся, микрофон готов к следующей записи). Это всего
|
||||||
log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме")
|
// подтверждение, что телефон отменил STT-сессию (stt.reset()) —
|
||||||
GlassesApp.instance.listening.value = false
|
// состояния (listening/thinking/оверлей) НЕ трогаем.
|
||||||
GlassesApp.instance.thinking.value = false
|
log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте")
|
||||||
}
|
}
|
||||||
SttCancel.REASON_TIMEOUT -> {
|
SttCancel.REASON_TIMEOUT -> {
|
||||||
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
|
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
|
||||||
|
|||||||
@@ -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,15 +48,13 @@ 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.SttText
|
||||||
import pw.binom.viewmate.core.protocol.StopStt
|
|
||||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||||
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
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.assistantMode
|
||||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
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
|
||||||
@@ -65,16 +63,11 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
|
|||||||
import pw.binom.viewmate.glasses.ui.Side
|
import pw.binom.viewmate.glasses.ui.Side
|
||||||
import pw.binom.viewmate.glasses.ui.px
|
import pw.binom.viewmate.glasses.ui.px
|
||||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||||
import pw.binom.viewmate.glasses.ui.verticalOffsetY
|
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`
|
||||||
@@ -265,7 +258,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
app.sttText.value = ""
|
app.sttText.value = ""
|
||||||
app.assistantText.value = ""
|
app.assistantText.value = ""
|
||||||
app.answerScrollDelta.value = 0f
|
app.answerScrollDelta.value = 0f
|
||||||
sttMic.start(filesDir) { app.listening.value }
|
app.sttSession.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click (кино): плей/пауза — как сегодня. */
|
/** Click (кино): плей/пауза — как сегодня. */
|
||||||
@@ -276,15 +269,13 @@ class MainActivity : BaseEventActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
|
||||||
private fun sendPhrase() {
|
private fun sendPhrase() {
|
||||||
log("gesture", "Click → отправить фразу")
|
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
|
||||||
sttMic.stop()
|
val app = GlassesApp.instance
|
||||||
GlassesApp.instance.listening.value = false
|
app.listening.value = false
|
||||||
GlassesApp.instance.thinking.value = true
|
app.thinking.value = true
|
||||||
lifecycleScope.launch {
|
app.sttSession.send()
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||||
@@ -296,7 +287,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
app.answerScrollDelta.value = 0f
|
app.answerScrollDelta.value = 0f
|
||||||
app.listening.value = true
|
app.listening.value = true
|
||||||
sttMic.start(filesDir) { app.listening.value }
|
app.sttSession.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -308,18 +299,51 @@ class MainActivity : BaseEventActivity() {
|
|||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
app.listening.value = true
|
app.listening.value = true
|
||||||
sttMic.start(filesDir) { app.listening.value }
|
app.sttSession.start()
|
||||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
/**
|
||||||
|
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся
|
||||||
|
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на
|
||||||
|
* экране (даже если истории диалога ещё нет — отмена не закрывает чат).
|
||||||
|
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]).
|
||||||
|
*/
|
||||||
private fun cancelDictation() {
|
private fun cancelDictation() {
|
||||||
log("gesture", "DoubleClick → отмена распознавания")
|
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
|
||||||
sttMic.stop()
|
val app = GlassesApp.instance
|
||||||
GlassesApp.instance.listening.value = false
|
app.sttSession.cancel()
|
||||||
GlassesApp.instance.thinking.value = false
|
app.sttText.value = ""
|
||||||
lifecycleScope.launch {
|
app.thinking.value = false
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
app.listening.value = true
|
||||||
|
app.sttSession.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
|
||||||
|
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
|
||||||
|
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
|
||||||
|
* слушать, пустой после лимита — выход из режима.
|
||||||
|
*/
|
||||||
|
fun onSttResult(text: String, auto: Boolean) {
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
app.sttText.value = text
|
||||||
|
when {
|
||||||
|
text.isNotBlank() -> {
|
||||||
|
app.listening.value = false
|
||||||
|
app.thinking.value = true
|
||||||
|
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон")
|
||||||
|
lifecycleScope.launch {
|
||||||
|
app.hostConnection.sendToHost(SttText(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
|
||||||
|
else -> {
|
||||||
|
log("stt", "клик без голоса — остаюсь в «слушаю»")
|
||||||
|
app.thinking.value = false
|
||||||
|
app.listening.value = true
|
||||||
|
app.sttSession.start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,10 +356,10 @@ class MainActivity : BaseEventActivity() {
|
|||||||
*/
|
*/
|
||||||
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||||
|
|
||||||
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
|
||||||
private fun finishAssistantMode(reason: String) {
|
private fun finishAssistantMode(reason: String) {
|
||||||
log("gesture", reason)
|
log("gesture", reason)
|
||||||
sttMic.stop()
|
GlassesApp.instance.sttSession.stop()
|
||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
app.listening.value = false
|
app.listening.value = false
|
||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
@@ -459,7 +483,6 @@ fun ConnectionScreen() {
|
|||||||
val status by connection.status.collectAsState()
|
val status by connection.status.collectAsState()
|
||||||
val messages by connection.messages.collectAsState()
|
val messages by connection.messages.collectAsState()
|
||||||
val scale by GlassesApp.instance.scale.collectAsState()
|
val scale by GlassesApp.instance.scale.collectAsState()
|
||||||
val vertical by GlassesApp.instance.vertical.collectAsState()
|
|
||||||
|
|
||||||
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
|
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
|
||||||
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
|
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
|
||||||
@@ -473,40 +496,42 @@ fun ConnectionScreen() {
|
|||||||
val containerW = (screenWidthPx * scale).toInt()
|
val containerW = (screenWidthPx * scale).toInt()
|
||||||
val containerH = (screenHeightPx * scale).toInt()
|
val containerH = (screenHeightPx * scale).toInt()
|
||||||
|
|
||||||
Box(
|
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||||
modifier = Modifier
|
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||||
.width(containerW.px)
|
Box(Modifier.fillMaxSize()) {
|
||||||
.height(containerH.px)
|
Box(
|
||||||
.offset(
|
modifier = Modifier
|
||||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
.width(containerW.px)
|
||||||
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
|
.height(containerH.px)
|
||||||
)
|
.offset(
|
||||||
.background(Color.Black),
|
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||||
) {
|
// вертикаль (vertical) — только для видео; здесь контент всегда у верха
|
||||||
Column(
|
)
|
||||||
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
|
.background(Color.Black),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
) {
|
||||||
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
|
Column(
|
||||||
Text(if (connected) "подключено к телефону" else "нет соединения")
|
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
|
||||||
Text(status, style = MaterialTheme.typography.bodyMedium)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
|
||||||
items(messages) { msg ->
|
Text(if (connected) "подключено к телефону" else "нет соединения")
|
||||||
Text(msg, style = MaterialTheme.typography.bodySmall)
|
Text(status, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
items(messages) { msg ->
|
||||||
|
Text(msg, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||||
}
|
}
|
||||||
|
|
||||||
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
|
||||||
}
|
|
||||||
|
|
||||||
// Панель статуса (время/заряд) — вне контейнера, всегда у верха половины:
|
|
||||||
// при vertical=0 контейнер у низа, и панель иначе повисала бы посередине.
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
StatusPanelCompose(
|
StatusPanelCompose(
|
||||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
modifier = Modifier
|
||||||
scale = scale,
|
.align(Alignment.BottomCenter)
|
||||||
|
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
||||||
|
|||||||
+32
-1
@@ -2,6 +2,7 @@ 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):
|
||||||
@@ -64,7 +65,12 @@ sealed interface GestureDecision {
|
|||||||
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
|
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
|
||||||
data object NewPhrase : GestureDecision
|
data object NewPhrase : GestureDecision
|
||||||
|
|
||||||
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */
|
/**
|
||||||
|
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
|
||||||
|
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
|
||||||
|
* оверлей остаётся на экране даже при пустой истории диалога.
|
||||||
|
* Сторона очков: StopStt(cancel=true) на телефон.
|
||||||
|
*/
|
||||||
data object CancelDictation : GestureDecision
|
data object CancelDictation : GestureDecision
|
||||||
|
|
||||||
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||||
@@ -125,6 +131,18 @@ fun gestureDecision(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»):
|
||||||
|
* возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый
|
||||||
|
* текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся
|
||||||
|
* на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true,
|
||||||
|
* т.к. listening=true). Закрытие ассистента — отдельное явное действие:
|
||||||
|
* двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]).
|
||||||
|
* Чистая функция — unit-тесты без Android.
|
||||||
|
*/
|
||||||
|
fun afterCancelDictation(state: AssistantState): AssistantState =
|
||||||
|
state.copy(listening = true, thinking = false, sttText = "")
|
||||||
|
|
||||||
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
|
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
|
||||||
fun overlayVisible(state: AssistantState): Boolean =
|
fun overlayVisible(state: AssistantState): Boolean =
|
||||||
state.listening || state.thinking || state.assistantText.isNotBlank()
|
state.listening || state.thinking || state.assistantText.isNotBlank()
|
||||||
@@ -142,6 +160,19 @@ fun overlayHeader(state: AssistantState): String? = when {
|
|||||||
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.movieController.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,41 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import pw.binom.asr.qwen3.Qwen3Config
|
||||||
|
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||||
|
import pw.binom.viewmate.glasses.log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
|
||||||
|
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
|
||||||
|
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
|
||||||
|
* [SttModelDownloader]).
|
||||||
|
*/
|
||||||
|
class GlassesAsr(
|
||||||
|
modelsDir: File,
|
||||||
|
numThreads: Int = 4,
|
||||||
|
) {
|
||||||
|
private val modelsDir = modelsDir
|
||||||
|
private val numThreads = numThreads
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
|
||||||
|
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
|
||||||
|
*/
|
||||||
|
private val recognizer by lazy {
|
||||||
|
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
|
||||||
|
createQwen3Sherpa(
|
||||||
|
Qwen3Config(
|
||||||
|
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
|
||||||
|
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
|
||||||
|
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
|
||||||
|
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
|
||||||
|
numThreads = numThreads,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
||||||
|
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
|
||||||
|
recognizer.recognize(samples, sampleRate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||||
|
import pw.binom.viewmate.core.stt.splitPhrases
|
||||||
|
import pw.binom.viewmate.core.stt.trimEdges
|
||||||
|
import pw.binom.viewmate.glasses.log
|
||||||
|
|
||||||
|
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
|
||||||
|
interface GlassesMic {
|
||||||
|
fun start(filesDir: File, shouldRun: () -> Boolean)
|
||||||
|
fun stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
||||||
|
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||||
|
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
||||||
|
*
|
||||||
|
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
||||||
|
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
|
||||||
|
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
|
||||||
|
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
|
||||||
|
* пусто после 30s-лимита (timeout) — автозавершение.
|
||||||
|
*
|
||||||
|
* @param onResult результат распознавания: text — фактический текст (может быть
|
||||||
|
* пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout).
|
||||||
|
*/
|
||||||
|
class GlassesSttSession(
|
||||||
|
private val filesDir: File,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val recognizer: (FloatArray) -> String,
|
||||||
|
private val modelReady: () -> Boolean,
|
||||||
|
private val onResult: (text: String, auto: Boolean) -> Unit,
|
||||||
|
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
|
||||||
|
) {
|
||||||
|
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
|
||||||
|
private val sessionLimitMs = 30_000L
|
||||||
|
|
||||||
|
private var mic: GlassesMic? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
|
||||||
|
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
|
||||||
|
*/
|
||||||
|
private val pcmLock = ReentrantLock()
|
||||||
|
private var pcm = ByteArray(0)
|
||||||
|
private var watchdog: Job? = null
|
||||||
|
@Volatile
|
||||||
|
private var running = false
|
||||||
|
|
||||||
|
val isRunning: Boolean get() = running
|
||||||
|
|
||||||
|
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
|
||||||
|
fun start() {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
pcmLock.withLock { pcm = ByteArray(0) }
|
||||||
|
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
||||||
|
mic = m
|
||||||
|
m.start(filesDir) { running }
|
||||||
|
watchdog = scope.launch {
|
||||||
|
delay(sessionLimitMs)
|
||||||
|
finish(auto = true)
|
||||||
|
}
|
||||||
|
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
|
||||||
|
fun send() = finish(auto = false)
|
||||||
|
|
||||||
|
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
|
||||||
|
fun cancel() {
|
||||||
|
stopMic()
|
||||||
|
watchdog?.cancel()
|
||||||
|
pcmLock.withLock { pcm = ByteArray(0) }
|
||||||
|
log("stt", "STT-сессия очков: отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полный стоп (выход из режима ассистента). */
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
stopMic()
|
||||||
|
watchdog?.cancel()
|
||||||
|
pcmLock.withLock { pcm = ByteArray(0) }
|
||||||
|
log("stt", "STT-сессия очков: закрыта")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(auto: Boolean) {
|
||||||
|
if (!running) return
|
||||||
|
running = false
|
||||||
|
stopMic()
|
||||||
|
watchdog?.cancel()
|
||||||
|
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
val text = if (audio.isNotEmpty() && modelReady()) {
|
||||||
|
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
|
||||||
|
phrases.joinToString(" ") { recognizer(it) }
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { onResult(text, auto) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopMic() {
|
||||||
|
mic?.stop()
|
||||||
|
mic = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,6 @@ package pw.binom.viewmate.glasses.stt
|
|||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
import android.media.AudioRecord
|
import android.media.AudioRecord
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import com.konovalov.vad.webrtc.VadWebRTC
|
|
||||||
import com.konovalov.vad.webrtc.config.FrameSize
|
|
||||||
import com.konovalov.vad.webrtc.config.Mode
|
|
||||||
import com.konovalov.vad.webrtc.config.SampleRate
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -19,37 +10,45 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.glasses.log
|
import pw.binom.viewmate.glasses.log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
|
* Поток микрофона очков (TASK-mic): 16 кГц s16 mono, полный поток БЕЗ VAD-нарезки.
|
||||||
*
|
*
|
||||||
* VAD живёт НА ОЧКАХ (WebRTC VAD, схема pair-view автора): микрофон читается
|
* История: WebRTC VAD резал речь на огрызки — микрофон ARGT78 пишет рвано
|
||||||
* фреймами по 20 мс (320 сэмплов @ 16 кГц), речевые фреймы накапливаются в
|
* (пачки ~50 мс звука / ~50 мс тишины), VAD принимал внутренние провалы за паузы
|
||||||
* сегмент (с пре-роллом тишины 200 мс), а наружу уходит только закрытый
|
* и ловил лишь ~15% речи. Локальный тест whisper на сыром дампе («1,2,3,4,5
|
||||||
* сегмент — межфразовая тишина (у микрофона ARGT78 её ~75%!) не шлётся.
|
* 6,7,8,9,10») распознал полный файл идеально — whisper сам справляется с
|
||||||
* Телефон получает склеенную речь — whisper видит непрерывный звук.
|
* рваностью и тихим уровнем (пик ~1500). Поэтому: весь PCM уходит на телефон
|
||||||
*
|
* чанками по 100 мс, усиление ×3 (без клиппа), телефон копит полный буфер
|
||||||
* Список кусков/стоп — по [shouldRun]: как только очки перестали слушать,
|
* сессии и шлёт его в whisper по клику.
|
||||||
* незакрытый сегмент отправляется (полный текст по клику).
|
|
||||||
*/
|
*/
|
||||||
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(
|
||||||
|
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
)
|
||||||
|
val bufferBytes = maxOf(minBuffer, MIC_CHUNK_FRAMES * FRAME_SIZE * 2)
|
||||||
val recorder = runCatching {
|
val recorder = runCatching {
|
||||||
AudioRecord(
|
AudioRecord(
|
||||||
MediaRecorder.AudioSource.MIC,
|
MediaRecorder.AudioSource.MIC,
|
||||||
16_000,
|
MIC_SAMPLE_RATE,
|
||||||
AudioFormat.CHANNEL_IN_MONO,
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
6_400,
|
bufferBytes,
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
@@ -66,48 +65,25 @@ class SttMicStream(
|
|||||||
job = scope.launch(Dispatchers.IO) {
|
job = scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
recorder.startRecording()
|
recorder.startRecording()
|
||||||
log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)")
|
log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)")
|
||||||
val vad = VadWebRTC(
|
val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс
|
||||||
SampleRate.SAMPLE_RATE_16K,
|
val chunk = ArrayList<ShortArray>(MIC_CHUNK_FRAMES)
|
||||||
FrameSize.FRAME_SIZE_320,
|
|
||||||
Mode.VERY_AGGRESSIVE,
|
|
||||||
600, // silenceDurationMs: тишина 600 мс = конец реплики
|
|
||||||
50, // speechDurationMs: минимум 50 мс речи
|
|
||||||
)
|
|
||||||
val frame = ShortArray(VAD_FRAME_SIZE) // 320 сэмплов = 20 мс
|
|
||||||
val segment = ArrayList<ShortArray>()
|
|
||||||
val preRoll = ArrayDeque<ShortArray>()
|
|
||||||
var speeching = false
|
|
||||||
var silenceFrames = 0
|
|
||||||
while (isActive && shouldRun()) {
|
while (isActive && shouldRun()) {
|
||||||
val n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры
|
val n = recorder.read(frame, 0, FRAME_SIZE) // кадры
|
||||||
if (n <= 0) continue
|
if (n <= 0) continue
|
||||||
val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n)
|
val data = if (n == FRAME_SIZE) frame else frame.copyOf(n)
|
||||||
val boosted = gainShort(data) // микрофон тихий (пик 1–9%) — ×10 и для VAD, и для whisper
|
// СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки.
|
||||||
if (vad.isSpeech(boosted)) {
|
if (dumpWriter != null) {
|
||||||
if (!speeching) {
|
runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) }
|
||||||
speeching = true
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
silenceFrames = 0
|
}
|
||||||
segment.clear()
|
chunk.add(gainShort(data, MIC_GAIN))
|
||||||
while (preRoll.isNotEmpty()) segment.add(preRoll.removeFirst())
|
if (chunk.size >= MIC_CHUNK_FRAMES) {
|
||||||
log("stt", "🎤 речь началась (${segment.size} фреймов с пре-роллом)")
|
onChunk(shortsToPcm16(chunk))
|
||||||
}
|
chunk.clear()
|
||||||
segment.add(boosted)
|
|
||||||
} else {
|
|
||||||
if (speeching) {
|
|
||||||
segment.add(boosted)
|
|
||||||
silenceFrames++
|
|
||||||
if (silenceFrames >= SILENCE_FRAMES_TIMEOUT) { // 600 мс тишины
|
|
||||||
closeSegment(segment, dumpWriter)
|
|
||||||
speeching = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
preRoll.addLast(boosted)
|
|
||||||
if (preRoll.size > PRE_ROLL_FRAMES) preRoll.removeFirst() // 200 мс
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter)
|
if (chunk.isNotEmpty()) onChunk(shortsToPcm16(chunk)) // остаток < 100 мс
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
log("stt", "микрофон: ${t.message}")
|
log("stt", "микрофон: ${t.message}")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,28 +96,15 @@ class SttMicStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
override fun stop() {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
job = null
|
||||||
runCatching { dump?.close() }
|
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
|
||||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Закрытый речевой сегмент: склейка фреймов → s16le → усилен в дамп и на телефон. */
|
|
||||||
private suspend fun closeSegment(segment: List<ShortArray>, dumpWriter: WavWriter?) {
|
|
||||||
if (segment.isEmpty()) return
|
|
||||||
val pcm = shortsToPcm16(segment)
|
|
||||||
if (dumpWriter != null) {
|
|
||||||
runCatching { dumpWriter.write(pcm) }
|
|
||||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
|
||||||
}
|
|
||||||
log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 160} мс → на телефон")
|
|
||||||
onChunk(pcm)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||||
* открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда
|
* открыть stt_dump_HHmmss.wav — сырой поток микрофона допишется туда
|
||||||
* (эмуляторную ветку не трогаем).
|
* (эмуляторную ветку не трогаем).
|
||||||
*/
|
*/
|
||||||
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||||
@@ -188,26 +151,27 @@ class SttMicStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов. */
|
/** Фрейм чтения микрофона: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
|
||||||
internal const val VAD_FRAME_SIZE = 320
|
internal const val FRAME_SIZE = 320
|
||||||
|
|
||||||
/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */
|
/** Чанк на телефон: 100 мс = 5 фреймов по 20 мс. */
|
||||||
internal const val PRE_ROLL_FRAMES = 10
|
internal const val MIC_CHUNK_FRAMES = 5
|
||||||
|
|
||||||
/** Тишина 600 мс (30 фреймов) считается концом реплики. */
|
/**
|
||||||
internal const val SILENCE_FRAMES_TIMEOUT = 30
|
* Коэффициент усиления микрофона: ×3 (без клиппа — ARGT78 пишет пик 1–9%, ×3 даёт
|
||||||
|
* 3–27%). Whisper распознал и сырой (пик 1521), и ×3, и ×10 — усиление нужно
|
||||||
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
|
* только чтобы телефонный whisper.cpp не терял тихие участки.
|
||||||
internal const val MIC_GAIN = 10f
|
*/
|
||||||
|
internal const val MIC_GAIN = 3f
|
||||||
|
|
||||||
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
||||||
internal const val CHUNK_BYTES = 3_200
|
internal const val CHUNK_BYTES = 3_200
|
||||||
|
|
||||||
/** Микрофон очков: родная 16 кГц (WebRTC VAD рекомендует 16 кГц, whisper тоже). */
|
/** Микрофон очков: 16 кГц — единственная рабочая частота ARGT78 (48 кГц молчит). */
|
||||||
internal const val MIC_SAMPLE_RATE = 16_000
|
internal const val MIC_SAMPLE_RATE = 16_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper).
|
* Цифровое усиление фрейма s16le mono с насыщением (для whisper).
|
||||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||||
*/
|
*/
|
||||||
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
|
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
|
||||||
@@ -219,7 +183,7 @@ internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Склейка речевых фреймов (ShortArray) в один s16le ByteArray. */
|
/** Склейка фреймов (ShortArray) в один s16le ByteArray. */
|
||||||
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
||||||
var total = 0
|
var total = 0
|
||||||
for (f in frames) total += f.size
|
for (f in frames) total += f.size
|
||||||
@@ -234,6 +198,33 @@ internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Даунсэмпл 48→16 кГц (сохранён для тестов; в проде не используется — 16 кГц напрямую):
|
||||||
|
* склейка 48 кГц-фреймов, FIR (скользящее среднее 3) против алиасинга, каждый 3-й сэмпл.
|
||||||
|
*/
|
||||||
|
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
|
||||||
|
var total = 0
|
||||||
|
for (f in frames) total += f.size
|
||||||
|
val outSize = total / 3
|
||||||
|
val out = ShortArray(outSize)
|
||||||
|
var src = 0
|
||||||
|
var dst = 0
|
||||||
|
var prev2 = 0
|
||||||
|
var prev1 = 0
|
||||||
|
for (f in frames) {
|
||||||
|
for (s in f) {
|
||||||
|
if (src % 3 == 2) {
|
||||||
|
val v = ((prev2 + prev1 + s) / 3)
|
||||||
|
if (dst < outSize) out[dst++] = v.toShort()
|
||||||
|
}
|
||||||
|
prev2 = prev1
|
||||||
|
prev1 = s.toInt()
|
||||||
|
src++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listOf(out)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
|
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
|
||||||
* 16-бит моно: кадр = 2 байта. Байты = frames * 2.
|
* 16-бит моно: кадр = 2 байта. Байты = frames * 2.
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import pw.binom.viewmate.glasses.log
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель Qwen3-ASR 0.6B int8 для очек: 4 компонента в [filesDir]/models
|
||||||
|
* (та же иерархия, что на телефоне — `PhoneApp.ensureStt`: conv/encoder/decoder +
|
||||||
|
* tokenizer/). Скачиваются с зеркала [SttModel.BASE_URL] при отсутствии локально:
|
||||||
|
* докачка с места обрыва (Range), проверка SHA-256, сквозной прогресс.
|
||||||
|
*
|
||||||
|
* Зеркало — внутренний `static.binom.pw/models/qwen3_06/` (обычная иерархия
|
||||||
|
* корневых .onnx + подкаталог tokenizer/), URL зашит константой.
|
||||||
|
*/
|
||||||
|
object SttModel {
|
||||||
|
/** Корень зеркала модели: обычные имена файлов + подкаталог tokenizer/. */
|
||||||
|
const val BASE_URL = "https://static.binom.pw/models/qwen3_06"
|
||||||
|
|
||||||
|
/** Иерархия модели + размер и SHA-256 каждого файла (контроль целостности). */
|
||||||
|
internal val FILES: List<ModelFile> = listOf(
|
||||||
|
ModelFile("conv_frontend.onnx", 44_148_281L, "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"),
|
||||||
|
ModelFile("encoder.int8.onnx", 182_491_662L, "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"),
|
||||||
|
ModelFile("decoder.int8.onnx", 755_914_231L, "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"),
|
||||||
|
ModelFile("tokenizer/merges.txt", 1_671_853L, "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"),
|
||||||
|
ModelFile("tokenizer/vocab.json", 2_776_833L, "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"),
|
||||||
|
ModelFile("tokenizer/tokenizer_config.json", 12_487L, "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один файл модели: относительный путь (в models/), размер и SHA-256. */
|
||||||
|
internal data class ModelFile(
|
||||||
|
val relPath: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val sha256Hex: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Быстрый «довольны ли файлом»: есть + размер совпадает (SHA проверяем на докатке). */
|
||||||
|
internal fun isSatisfied(modelsDir: File, f: ModelFile): Boolean {
|
||||||
|
val final = File(modelsDir, f.relPath)
|
||||||
|
return final.isFile && final.length() == f.sizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Какие файлы [files] нужны (отсутствуют или битые по размеру). */
|
||||||
|
internal fun whichFilesNeeded(modelsDir: File, files: List<ModelFile>): List<ModelFile> =
|
||||||
|
files.filter { !isSatisfied(modelsDir, it) }
|
||||||
|
|
||||||
|
/** Модель на диске полностью: все файлы [files] на месте с верным размером. */
|
||||||
|
internal fun isModelComplete(modelsDir: File, files: List<ModelFile> = SttModel.FILES): Boolean =
|
||||||
|
whichFilesNeeded(modelsDir, files).isEmpty()
|
||||||
|
|
||||||
|
/** Все байты всех файлов модели (для сквозного процента). */
|
||||||
|
internal fun modelTotalBytes(files: List<ModelFile> = SttModel.FILES): Long = files.sumOf { it.sizeBytes }
|
||||||
|
|
||||||
|
/** SHA-256 файла (потоком, без загрузки всего в память). */
|
||||||
|
internal fun sha256Hex(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
val buf = ByteArray(1 shl 16)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
digest.update(buf, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Статус модели: что происходит со скачиванием (для индикатора в UI очков). */
|
||||||
|
data class SttModelStatus(
|
||||||
|
val phase: Phase,
|
||||||
|
val percent: Int = 0,
|
||||||
|
val detail: String = "",
|
||||||
|
) {
|
||||||
|
enum class Phase { MISSING, DOWNLOADING, READY, FAILED }
|
||||||
|
|
||||||
|
val ready: Boolean get() = phase == Phase.READY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивание модели Qwen3-ASR в [modelsDir] (filesDir/models). Прогресс — в [status].
|
||||||
|
* Идиомпотентно: [ensure] не делает ничего, если модель уже на месте; иначе докачивает
|
||||||
|
* недостающие/битые файлы с Range и проверяет SHA-256.
|
||||||
|
*/
|
||||||
|
class SttModelDownloader(
|
||||||
|
private val modelsDir: File,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val okHttp: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // большие файлы, паузы в потоке
|
||||||
|
.build(),
|
||||||
|
) {
|
||||||
|
private val _status = MutableStateFlow(SttModelStatus(SttModelStatus.Phase.MISSING))
|
||||||
|
val status: StateFlow<SttModelStatus> = _status.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var downloadJob: Job? = null
|
||||||
|
|
||||||
|
/** Таймер авто-повтора после ошибки (сеть могла появиться позже — см. [scheduleRetry]). */
|
||||||
|
@Volatile
|
||||||
|
private var retryJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Пауза до авто-повтора скачивания после FAILED: сеть могла появиться позже. */
|
||||||
|
private const val RETRY_DELAY_MS = 15_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перечитать с диска: READY (готово) или MISSING (не хватает). */
|
||||||
|
fun refresh() {
|
||||||
|
_status.value = if (isModelComplete(modelsDir)) {
|
||||||
|
SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||||
|
} else {
|
||||||
|
SttModelStatus(SttModelStatus.Phase.MISSING, overallPercent(), "нет локально — скачаю при старте")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачать недостающее (idempotent; повторный вызов пока идёт — игнор). */
|
||||||
|
fun ensure() {
|
||||||
|
if (isModelComplete(modelsDir)) {
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (downloadJob?.isActive == true) return
|
||||||
|
downloadJob = scope.launch {
|
||||||
|
try {
|
||||||
|
val total = modelTotalBytes()
|
||||||
|
val needed = whichFilesNeeded(modelsDir, SttModel.FILES)
|
||||||
|
var done = total - needed.sumOf { it.sizeBytes } // байты уже готовых файлов
|
||||||
|
needed.forEach { f ->
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
downloadOne(f) { fileDone ->
|
||||||
|
val overall = ((done + fileDone) * 100 / total).toInt().coerceIn(0, 100)
|
||||||
|
_status.value = SttModelStatus(SttModelStatus.Phase.DOWNLOADING, overall, "${f.relPath}: $overall%")
|
||||||
|
}
|
||||||
|
done += f.sizeBytes
|
||||||
|
log("stt", "модель: скачан ${f.relPath} (${f.sizeBytes} Б)")
|
||||||
|
}
|
||||||
|
_status.value = SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||||
|
log("stt", "модель Qwen3-ASR готова в ${modelsDir.absolutePath}")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("stt", "ошибка скачивания модели: ${e.message}")
|
||||||
|
_status.value = SttModelStatus(SttModelStatus.Phase.FAILED, overallPercent(), "ошибка: ${e.message}")
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авто-повтор после [SttModelStatus.Phase.FAILED]: сеть могла появиться позже
|
||||||
|
* (Wi-Fi очков выключен при старте, DNS не резолвится — фиксированные случаи).
|
||||||
|
* Один таймер: новый провал перезапускает его; при готовности/идущем скачивании
|
||||||
|
* [ensure] — no-op, так что повторы сами завершаются.
|
||||||
|
*/
|
||||||
|
private fun scheduleRetry() {
|
||||||
|
retryJob?.cancel()
|
||||||
|
retryJob = scope.launch {
|
||||||
|
delay(RETRY_DELAY_MS)
|
||||||
|
log("stt", "модель: авто-повтор скачивания (ошибка могла быть сессионной — сеть)")
|
||||||
|
ensure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачать один файл: Range-докачка в .part, SHA-256, rename в финальный. onProgress — байты файла. */
|
||||||
|
private suspend fun downloadOne(f: ModelFile, onProgress: (Long) -> Unit) {
|
||||||
|
val parent = File(modelsDir, f.relPath).parentFile
|
||||||
|
parent?.mkdirs()
|
||||||
|
val final = File(modelsDir, f.relPath)
|
||||||
|
val part = File(modelsDir, "$f.relPath.part")
|
||||||
|
val existing = if (part.exists()) part.length() else 0L
|
||||||
|
|
||||||
|
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
|
||||||
|
if (existing > 0) builder.header("Range", "bytes=$existing-")
|
||||||
|
|
||||||
|
okHttp.newCall(builder.build()).execute().use { response ->
|
||||||
|
val code = response.code
|
||||||
|
if (code != 200 && code != 206) error("HTTP $code для ${f.relPath}")
|
||||||
|
val body = response.body
|
||||||
|
|
||||||
|
val resumed = code == 206 && existing > 0
|
||||||
|
val input = body.byteStream()
|
||||||
|
FileOutputStream(part, resumed).use { out ->
|
||||||
|
var done = if (resumed) existing else 0L
|
||||||
|
onProgress(done)
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
done += read
|
||||||
|
onProgress(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sha256Hex(part) != f.sha256Hex) {
|
||||||
|
part.delete()
|
||||||
|
error("SHA-256 не совпал для ${f.relPath}")
|
||||||
|
}
|
||||||
|
if (final.exists()) final.delete()
|
||||||
|
if (!part.renameTo(final)) {
|
||||||
|
part.copyTo(final, overwrite = true)
|
||||||
|
part.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сквозной процент: сколько всех байтов модели уже на диске (по размеру). */
|
||||||
|
private fun overallPercent(): Int {
|
||||||
|
val total = modelTotalBytes()
|
||||||
|
if (total <= 0) return 0
|
||||||
|
val have = SttModel.FILES.filter { isSatisfied(modelsDir, it) }.sumOf { it.sizeBytes }
|
||||||
|
return (have * 100 / total).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,13 +32,17 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
|
||||||
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
* каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
|
||||||
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
* «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
|
||||||
|
* в том же scale-контейнере, что у экранов. Режим общения явный
|
||||||
|
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
|
||||||
|
* Состояния: «слушаю»/«думаю...» — заголовок,
|
||||||
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||||
* [GlassesApp.answerScrollDelta]).
|
* [GlassesApp.answerScrollDelta]).
|
||||||
*/
|
*/
|
||||||
@@ -52,6 +56,10 @@ fun AssistantOverlay() {
|
|||||||
val chatHistory by app.chatHistory.collectAsState()
|
val chatHistory by app.chatHistory.collectAsState()
|
||||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||||
if (!overlayVisible(state)) return
|
if (!overlayVisible(state)) return
|
||||||
|
val modelStatus by app.sttModel.status.collectAsState()
|
||||||
|
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
|
||||||
|
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
|
||||||
|
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null
|
||||||
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||||
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||||
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||||
@@ -78,20 +86,26 @@ fun AssistantOverlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
val vertical by app.vertical.collectAsState()
|
|
||||||
val screenWidthPx = BinocularScreenSize.current.width
|
val screenWidthPx = BinocularScreenSize.current.width
|
||||||
val screenHeightPx = BinocularScreenSize.current.height
|
val screenHeightPx = BinocularScreenSize.current.height
|
||||||
val side = BinocularScreenSide.current
|
val side = BinocularScreenSide.current
|
||||||
@@ -99,24 +113,29 @@ 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)
|
.background(Color.Black),
|
||||||
.offset(
|
|
||||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
|
||||||
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
|
|
||||||
)
|
|
||||||
.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) {
|
||||||
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),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +146,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()
|
||||||
@@ -147,6 +167,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(
|
||||||
|
|||||||
@@ -104,46 +104,47 @@ fun GlassesMovieScreen(
|
|||||||
maxHeight = containerH - 25,
|
maxHeight = containerH - 25,
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
// Оверлей-обёртка половины: контейнер (смещается scale/vertical) + панель статуса
|
||||||
modifier = Modifier
|
// поверх него. Панель — у низа экрана при любом масштабе/вертикали.
|
||||||
.width(containerW.px)
|
Box(Modifier.fillMaxSize()) {
|
||||||
.height(containerH.px)
|
Box(
|
||||||
.offset(
|
modifier = Modifier
|
||||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
.width(containerW.px)
|
||||||
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
|
.height(containerH.px)
|
||||||
),
|
.offset(
|
||||||
) {
|
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||||
Row(
|
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
|
||||||
modifier = Modifier.fillMaxWidth().background(Color.Black),
|
),
|
||||||
// Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
|
|
||||||
// Row-центрирование съедало вертикаль — картинка оставалась «по центру».
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
|
|
||||||
) {
|
) {
|
||||||
if (SCALE_DEBUG_SQUARE) {
|
Row(
|
||||||
// Тестовый режим: большой красный квадрат вместо видео — быстрая
|
modifier = Modifier.fillMaxWidth().background(Color.Black),
|
||||||
// проверка масштаба/позиций скрином без запуска видео.
|
// Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
|
||||||
Box(Modifier.fillMaxSize().background(Color.Red))
|
// Row-центрирование съедало вертикаль — картинка оставалась «по центру».
|
||||||
} else {
|
verticalAlignment = Alignment.Top,
|
||||||
BinocularVideoPlayer(
|
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
|
||||||
player = player,
|
) {
|
||||||
renderer = renderer,
|
if (SCALE_DEBUG_SQUARE) {
|
||||||
modifier = Modifier
|
// Тестовый режим: большой красный квадрат вместо видео — быстрая
|
||||||
.size(playerWidth.px, playerHeight.px)
|
// проверка масштаба/позиций скрином без запуска видео.
|
||||||
.offset(y = videoOffsetY(containerH, playerHeight, vertical).px),
|
Box(Modifier.fillMaxSize().background(Color.Red))
|
||||||
)
|
} else {
|
||||||
|
BinocularVideoPlayer(
|
||||||
|
player = player,
|
||||||
|
renderer = renderer,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(playerWidth.px, playerHeight.px)
|
||||||
|
.offset(y = videoOffsetY(containerH, playerHeight, vertical).px),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Панель статуса (время/заряд) — вне контейнера, всегда у верха половины:
|
|
||||||
// при vertical=0 контейнер у низа, и панель иначе повисала бы посередине
|
|
||||||
// чёрного поля между верхом экрана и видео.
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
StatusPanelCompose(
|
StatusPanelCompose(
|
||||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
modifier = Modifier
|
||||||
scale = scale,
|
.align(Alignment.BottomCenter)
|
||||||
|
.offset(x = panelOffsetX(side, screenWidthPx, scale).px)
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
scale = scale, // панель масштабируется вместе с контентом, но прибита к низу
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,6 +173,14 @@ fun verticalOffsetY(screenHeightPx: Int, scale: Float, vertical: Float): Int =
|
|||||||
fun videoOffsetY(containerHeightPx: Int, videoHeightPx: Int, vertical: Float): Int =
|
fun videoOffsetY(containerHeightPx: Int, videoHeightPx: Int, vertical: Float): Int =
|
||||||
((1f - vertical) * (containerHeightPx - videoHeightPx).coerceAtLeast(0)).toInt()
|
((1f - vertical) * (containerHeightPx - videoHeightPx).coerceAtLeast(0)).toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Горизонтальный сдвиг панели статуса к центру (к носу), чтобы её центр совпадал
|
||||||
|
* с центром контейнера при любом масштабе: LEFT — вправо, RIGHT — влево,
|
||||||
|
* на половину хода контейнера ((1 - scale) * W / 2).
|
||||||
|
*/
|
||||||
|
fun panelOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
|
||||||
|
((1f - scale) * screenWidthPx / 2f).toInt() * if (side == Side.LEFT) 1 else -1
|
||||||
|
|
||||||
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
|
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
|
||||||
private const val SCALE_DEBUG_SQUARE = false
|
private const val SCALE_DEBUG_SQUARE = false
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pw.binom.viewmate.glasses.ui.Side
|
|||||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||||
import pw.binom.viewmate.glasses.ui.verticalOffsetY
|
import pw.binom.viewmate.glasses.ui.verticalOffsetY
|
||||||
import pw.binom.viewmate.glasses.ui.videoOffsetY
|
import pw.binom.viewmate.glasses.ui.videoOffsetY
|
||||||
|
import pw.binom.viewmate.glasses.ui.panelOffsetX
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
|
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
|
||||||
@@ -88,4 +89,29 @@ class ScaleOffsetTest {
|
|||||||
fun videoLargerThanContainer_noNegativeOffset() {
|
fun videoLargerThanContainer_noNegativeOffset() {
|
||||||
assertEquals(0, videoOffsetY(200, 268, 0f), "видео выше контейнера — хода нет")
|
assertEquals(0, videoOffsetY(200, 268, 0f), "видео выше контейнера — хода нет")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* panelOffsetX — панель статуса сдвигается к носу, как контейнер:
|
||||||
|
* LEFT — вправо, RIGHT — влево, на (1 - scale) * W / 2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun panelFullScale_noOffset() {
|
||||||
|
assertEquals(0, panelOffsetX(Side.LEFT, 640, 1f))
|
||||||
|
assertEquals(0, panelOffsetX(Side.RIGHT, 640, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun panelHalfScale_movesTowardNose() {
|
||||||
|
// 640 * 0.5 / 2 = 160: LEFT вправо, RIGHT влево
|
||||||
|
assertEquals(160, panelOffsetX(Side.LEFT, 640, 0.5f))
|
||||||
|
assertEquals(-160, panelOffsetX(Side.RIGHT, 640, 0.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun panelScale07_offset() {
|
||||||
|
// 640 * 0.3 / 2 = 96
|
||||||
|
assertEquals(96, panelOffsetX(Side.LEFT, 640, 0.7f))
|
||||||
|
assertEquals(-96, panelOffsetX(Side.RIGHT, 640, 0.7f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-4
@@ -15,6 +15,7 @@ import kotlin.test.assertFalse
|
|||||||
import kotlin.test.assertNull
|
import kotlin.test.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): переходы состояний, свайпы → ±30с в кино,
|
||||||
@@ -150,10 +151,43 @@ class AssistantGesturesTest {
|
|||||||
fun cancelStaysInConversation() {
|
fun cancelStaysInConversation() {
|
||||||
val decision = gestureDecision(DoubleClick(), listening)
|
val decision = gestureDecision(DoubleClick(), listening)
|
||||||
assertEquals(GestureDecision.CancelDictation, decision)
|
assertEquals(GestureDecision.CancelDictation, decision)
|
||||||
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick.
|
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
|
||||||
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
|
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
|
||||||
assertEquals(AssistantMode.ANSWER, assistantMode(after))
|
val after = afterCancelDictation(listening)
|
||||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
|
assertEquals(AssistantMode.LISTENING, assistantMode(after))
|
||||||
|
assertTrue(overlayVisible(after))
|
||||||
|
// Двойной тап в «слушаю» после отмены — снова просто отмена новой записи.
|
||||||
|
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), after))
|
||||||
|
// Отмена посреди диалога: пользователь говорит новую фразу → ответ виден
|
||||||
|
// (listening уже закрыт отправкой) — выход (как и раньше) DoubleClick.
|
||||||
|
val afterWithAnswer = after.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
|
||||||
|
assertEquals(AssistantMode.ANSWER, assistantMode(afterWithAnswer))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), afterWithAnswer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отмена первой записи (пустая история): оверлей НЕ закрывается (TZ-ui-four п.1). */
|
||||||
|
@Test
|
||||||
|
fun cancelFirstRecordingKeepsOverlay() {
|
||||||
|
// Чистый «кино»-снимок → «слушаю» (вызов ассистента) → отмена записи.
|
||||||
|
val listeningNow = movie.copy(listening = true)
|
||||||
|
val cancelled = afterCancelDictation(listeningNow)
|
||||||
|
assertTrue(cancelled.assistantText.isBlank())
|
||||||
|
assertTrue(cancelled.listening)
|
||||||
|
// Оверлей виден, хотя ни фразы, ни ответа нет — «слушай готов».
|
||||||
|
assertTrue(overlayVisible(cancelled))
|
||||||
|
// И только явный выход (после диалога) прячет оверлей.
|
||||||
|
val exited = cancelled.copy(listening = false, thinking = false, assistantText = "ответ")
|
||||||
|
val closed = exited.copy(listening = false, thinking = false, assistantText = "")
|
||||||
|
assertFalse(overlayVisible(closed))
|
||||||
|
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), closed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отмена сбрасывает накопленный текст текущей (отменяемой) записи. */
|
||||||
|
@Test
|
||||||
|
fun cancelDictationClearsCancelledText() {
|
||||||
|
val after = afterCancelDictation(listening.copy(sttText = "слова до отмены"))
|
||||||
|
assertEquals("", after.sttText)
|
||||||
|
assertTrue(after.listening)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— оверлей —————
|
// ————— оверлей —————
|
||||||
@@ -194,6 +228,33 @@ class AssistantGesturesTest {
|
|||||||
assertNull(overlayHeader(movie))
|
assertNull(overlayHeader(movie))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— строка статуса модели —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelStatusLineReadyIsNull() {
|
||||||
|
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelStatusLineDownloadingShowsPercent() {
|
||||||
|
assertEquals(
|
||||||
|
"модель качается: 45%",
|
||||||
|
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelStatusLineMissingAndFailed() {
|
||||||
|
assertEquals(
|
||||||
|
"модель не скачана — распознавание недоступно",
|
||||||
|
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"модель не скачалась — повторю автоматически",
|
||||||
|
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ————— скролл ответа —————
|
// ————— скролл ответа —————
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -48,30 +48,30 @@ class MicChunkTest {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
|
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
|
||||||
* gainChunk ×10 поднимает речь до уровня, который видят Silero VAD/Whisper.
|
* gainChunk ×5 поднимает речь для whisper; VAD детектит на сыром сигнале.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun quietChunk_gained10_peakReachesAtLeast9000_sizeKept() {
|
fun quietChunk_gained3_peakReachesAtLeast2700_sizeKept() {
|
||||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
|
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
|
||||||
val out = gainChunk(chunk)
|
val out = gainChunk(chunk)
|
||||||
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
||||||
val peak = s16Samples(out).maxOf { abs(it) }
|
val peak = s16Samples(out).maxOf { abs(it) }
|
||||||
assertTrue(peak >= 9_000, "тихий пик 1000 × 10 должен дать ≥ 9000, а не $peak")
|
assertTrue(peak >= 2_700, "тихий пик 1000 × 3 должен дать ≥ 2700, а не $peak")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun loudChunk_gained10_clampedInsideS16Range() {
|
fun loudChunk_gained3_clampedInsideS16Range() {
|
||||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
|
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
|
||||||
val out = gainChunk(chunk)
|
val out = gainChunk(chunk)
|
||||||
val s = s16Samples(out)
|
val s = s16Samples(out)
|
||||||
assertEquals(32_767, s.max(), "30000 × 10 = 300000 → насыщение до 32767")
|
assertEquals(32_767, s.max(), "30000 × 3 = 90000 → насыщение до 32767")
|
||||||
assertEquals(-32_768, s.min(), "-30000 × 10 = -300000 → насыщение до -32768")
|
assertEquals(-32_768, s.min(), "-30000 × 3 = -90000 → насыщение до -32768")
|
||||||
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun zeroChunk_gained10_bytesUnchanged() {
|
fun zeroChunk_gained3_bytesUnchanged() {
|
||||||
val chunk = ByteArray(CHUNK_BYTES)
|
val chunk = ByteArray(CHUNK_BYTES)
|
||||||
val out = gainChunk(chunk)
|
val out = gainChunk(chunk)
|
||||||
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
||||||
@@ -88,8 +88,8 @@ class MicChunkTest {
|
|||||||
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
||||||
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
||||||
val s = s16Samples(gainChunk(chunk))
|
val s = s16Samples(gainChunk(chunk))
|
||||||
assertEquals(-1_230, s[0], "первый сэмпл: знак сохранён и усилен ×10")
|
assertEquals(-369, s[0], "первый сэмпл: знак сохранён и усилен ×3")
|
||||||
assertEquals(4_560, s[1], "второй сэмпл: порядок сохранён и усилен ×10")
|
assertEquals(1_368, s[1], "второй сэмпл: порядок сохранён и усилен ×3")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
||||||
@@ -125,14 +125,14 @@ class MicChunkTest {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun gainShort_quietSamples_boosted10_saturated() {
|
fun gainShort_quietSamples_boosted5_saturated() {
|
||||||
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
|
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
|
||||||
val out = gainShort(samples, 10f)
|
val out = gainShort(samples, 5f)
|
||||||
assertEquals(samples.size, out.size)
|
assertEquals(samples.size, out.size)
|
||||||
assertEquals(10_000, out[0].toInt(), "1000 × 10")
|
assertEquals(5_000, out[0].toInt(), "1000 × 5")
|
||||||
assertEquals(-10_000, out[1].toInt(), "-1000 × 10")
|
assertEquals(-5_000, out[1].toInt(), "-1000 × 5")
|
||||||
assertEquals(32_767, out[2].toInt(), "20000 × 10 → насыщение")
|
assertEquals(32_767, out[2].toInt(), "20000 × 5 → насыщение")
|
||||||
assertEquals(-32_768, out[3].toInt(), "-32768 × 10 → насыщение")
|
assertEquals(-32_768, out[3].toInt(), "-32768 × 5 → насыщение")
|
||||||
assertEquals(0, out[4].toInt())
|
assertEquals(0, out[4].toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +142,27 @@ class MicChunkTest {
|
|||||||
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
|
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downsample48To16_thirdLengthAndFIR() {
|
||||||
|
// 9 сэмплов 48 кГц (3 фрейма по 3) → 3 сэмпла 16 кГц
|
||||||
|
val frames = listOf(shortArrayOf(3, 6, 9), shortArrayOf(12, 15, 18), shortArrayOf(21, 24, 27))
|
||||||
|
val out = downsample48To16(frames)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
assertEquals(3, out[0].size, "длина = 1/3")
|
||||||
|
// тройки (3,6,9) → 6; (12,15,18) → 15; (21,24,27) → 24
|
||||||
|
assertEquals(6, out[0][0].toInt())
|
||||||
|
assertEquals(15, out[0][1].toInt())
|
||||||
|
assertEquals(24, out[0][2].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downsample48To16_shortSegment_keepsFloor() {
|
||||||
|
// 5 сэмплов → 1 (5/3 = 1)
|
||||||
|
val out = downsample48To16(listOf(shortArrayOf(1, 2, 3, 4, 5)))
|
||||||
|
assertEquals(1, out[0].size)
|
||||||
|
assertEquals(2, out[0][0].toInt(), "среднее (1,2,3)")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shortsToPcm16_concatenatesLittleEndian() {
|
fun shortsToPcm16_concatenatesLittleEndian() {
|
||||||
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
|
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,11 +98,18 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
||||||
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s
|
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
|
||||||
* рассылаются через [broadcast].
|
* рассылаются через [broadcast].
|
||||||
*/
|
*/
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -107,8 +117,8 @@ class GlassesHub(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
||||||
* на StopStt(cancel=false) с непустым текстом. VAD-фразы в LLM НЕ уходят —
|
* на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
|
||||||
* только повторный клик пользователя.
|
* отсутствуют — только полный текст (по клику или по тишине 30 с).
|
||||||
*/
|
*/
|
||||||
@Volatile
|
@Volatile
|
||||||
var onStopFullText: ((String) -> Unit)? = null
|
var onStopFullText: ((String) -> 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).
|
||||||
@@ -38,6 +40,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 +64,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 +112,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 +161,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 +194,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} с")
|
||||||
@@ -174,13 +241,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 +405,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,17 +17,23 @@ 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
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
import pw.binom.viewmate.core.protocol.SttDone
|
||||||
import pw.binom.viewmate.phone.agent.AgentToolkit
|
import pw.binom.viewmate.phone.agent.AgentToolkit
|
||||||
import pw.binom.viewmate.phone.agent.Assistant
|
import pw.binom.viewmate.phone.agent.Assistant
|
||||||
import pw.binom.viewmate.phone.agent.ChatDao
|
import pw.binom.viewmate.phone.agent.ChatDao
|
||||||
@@ -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,9 +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.SherpaStreamingVad
|
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,
|
||||||
@@ -83,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)) }
|
||||||
|
|
||||||
@@ -118,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) доступен только после; ленивость —
|
||||||
@@ -214,8 +250,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 режет генерацию).
|
||||||
@@ -344,50 +383,53 @@ 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 vadModel = File(modelsDir, "silero_vad.onnx")
|
val qwenConv = File(modelsDir, "conv_frontend.onnx")
|
||||||
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
|
||||||
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
val qwenDec = File(modelsDir, "decoder.int8.onnx")
|
||||||
val tokens = File(modelsDir, "small-tokens.txt")
|
val qwenTok = File(modelsDir, "tokenizer")
|
||||||
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
|
||||||
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
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,
|
||||||
vad = SherpaStreamingVad(vadModel.absolutePath),
|
|
||||||
onPhrase = { phrase, full ->
|
|
||||||
log("stt", "фраза: $phrase")
|
|
||||||
// Только показ на очках — в LLM фраза НЕ уходит (только по клику).
|
|
||||||
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
|
||||||
},
|
|
||||||
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")) }
|
||||||
},
|
},
|
||||||
|
onAutoFinished = { full ->
|
||||||
|
log("stt", "тишина 30 с — ВЕСЬ ТЕКСТ: $full")
|
||||||
|
// Тот же путь, что и результат по клику: в очки SttDone + в LLM.
|
||||||
|
scope.launch { server.hub.broadcast(SttDone(full)) }
|
||||||
|
scope.launch { assistantChannel.send(full.trim()) }
|
||||||
|
},
|
||||||
dumpPcm = dumpPcm,
|
dumpPcm = dumpPcm,
|
||||||
)
|
)
|
||||||
sttStreamer = s
|
sttStreamer = s
|
||||||
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||||
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
log("stt", "SttStreamer готов (qwen3_asr)")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,9 +472,31 @@ 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() }
|
||||||
|
|
||||||
val lastPositionAt = AtomicLong(0)
|
val lastPositionAt = AtomicLong(0)
|
||||||
@@ -440,11 +504,25 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
// каждый 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: очки молчат (экран погас/радио спит) — пауза звука по таймауту
|
||||||
@@ -514,5 +592,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,59 +3,42 @@ package pw.binom.viewmate.phone.stt
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.Future
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
import 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
|
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||||
* Whisper (в отдельном потоке) → колбэк [onPhrase].
|
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||||
*
|
* [PhraseRecognizer] с предварительным обрезанием краевой тишины ([trimEdges]).
|
||||||
* Весь PCM сессии дополнительно накапливается без зависимости от VAD и в
|
|
||||||
* [finish] (по клику) распознаётся одним вызовом (хвост 30 с, если больше),
|
|
||||||
* перезаписывая VAD-текст; VAD-фразы остаются только промежуточными
|
|
||||||
* ([onPhrase]).
|
|
||||||
*
|
*
|
||||||
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
|
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
|
||||||
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
|
* идёт в однопоточном executor-е (не блокирует приём аудио).
|
||||||
*
|
*
|
||||||
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s]
|
* Тишина [SILENCE_TIMEOUT_MS] (с момента первого [accept]) → автo-финиш:
|
||||||
* (автo-отмена, вызывается один раз за сессию).
|
* накопленный буфер распознаётся (как [finish]) и результат отдаётся в
|
||||||
|
* [onAutoFinished], затем сессия сбрасывается и вызывается [onSilence30s]
|
||||||
|
* (автo-отмена, один раз за сессию).
|
||||||
*/
|
*/
|
||||||
class SttStreamer(
|
class SttStreamer(
|
||||||
private val stt: PhraseRecognizer,
|
private val stt: PhraseRecognizer,
|
||||||
private val vad: StreamingVad,
|
|
||||||
private val onPhrase: (phrase: String, full: String) -> Unit,
|
|
||||||
private val onSilence30s: () -> Unit,
|
private val onSilence30s: () -> Unit,
|
||||||
|
/** Полный текст сессии, распознанный автo-финишем по тишине (см. [onSilence30s]). */
|
||||||
|
private val onAutoFinished: (full: String) -> Unit,
|
||||||
private val dumpPcm: File? = null,
|
private val dumpPcm: File? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val SILENCE_TIMEOUT_MS = 30_000L
|
private const val SILENCE_TIMEOUT_MS = 30_000L
|
||||||
private const val WINDOW_SIZE = 512
|
|
||||||
|
|
||||||
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
|
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
|
||||||
private const val SAMPLE_RATE = 16_000
|
private const val SAMPLE_RATE = 16_000
|
||||||
|
|
||||||
/** Максимум PCM для распознания по клику: 30 с (дольше — последние 30 с). */
|
/** Максимум PCM для распознавания: 120 с (дольше — последние 120 с). */
|
||||||
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 30
|
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 120
|
||||||
}
|
}
|
||||||
|
|
||||||
private val lock = java.util.concurrent.locks.ReentrantLock()
|
private val lock = java.util.concurrent.locks.ReentrantLock()
|
||||||
@@ -87,28 +70,28 @@ class SttStreamer(
|
|||||||
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
|
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
/** Весь PCM сессии (FloatArray @16 кГц): [finish] прогоняет его через распознаватель одним вызовом. */
|
||||||
private var pending = FloatArray(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Весь PCM сессии (FloatArray @16 кГц) — не зависит от VAD-сегментации:
|
|
||||||
* [finish] прогоняет его через распознаватель одним вызовом.
|
|
||||||
*/
|
|
||||||
private var sessionPcm: FloatArray = FloatArray(0)
|
private var sessionPcm: FloatArray = FloatArray(0)
|
||||||
|
|
||||||
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
|
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
|
||||||
private var lastFuture: Future<*>? = null
|
private var lastFuture: java.util.concurrent.Future<*>? = null
|
||||||
|
|
||||||
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
|
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
|
||||||
while (true) {
|
while (true) {
|
||||||
val deadline = deadlineMs
|
val deadline = deadlineMs
|
||||||
if (deadline > 0 && System.currentTimeMillis() > deadline) {
|
if (deadline > 0 && System.currentTimeMillis() > deadline) {
|
||||||
lock.withLock {
|
// Снимаем срок и освобождаем lock до распознавания — watchdog не держит
|
||||||
if (deadlineMs == deadline) {
|
// lock при вызове finish (иначе блокировка-в-блокировке c accept).
|
||||||
deadlineMs = 0
|
val expired = lock.withLock {
|
||||||
resetLocked()
|
if (deadlineMs != deadline) return@withLock false
|
||||||
onSilence30s()
|
deadlineMs = 0
|
||||||
}
|
true
|
||||||
|
}
|
||||||
|
if (expired) {
|
||||||
|
val fullText = finish()
|
||||||
|
if (fullText.isNotBlank()) onAutoFinished(fullText)
|
||||||
|
lock.withLock { resetLocked() }
|
||||||
|
onSilence30s()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
|
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
|
||||||
@@ -121,27 +104,6 @@ class SttStreamer(
|
|||||||
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
||||||
val floats = s16leToFloat(pcm)
|
val floats = s16leToFloat(pcm)
|
||||||
sessionPcm = sessionPcm + floats
|
sessionPcm = sessionPcm + floats
|
||||||
var i = 0
|
|
||||||
|
|
||||||
if (pending.isNotEmpty()) {
|
|
||||||
val need = WINDOW_SIZE - pending.size
|
|
||||||
val take = minOf(need, floats.size)
|
|
||||||
val combined = pending + floats.copyOfRange(0, take)
|
|
||||||
i += take
|
|
||||||
if (combined.size == WINDOW_SIZE) {
|
|
||||||
feedWindow(combined)
|
|
||||||
pending = FloatArray(0)
|
|
||||||
} else {
|
|
||||||
pending = combined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (i + WINDOW_SIZE <= floats.size) {
|
|
||||||
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE))
|
|
||||||
i += WINDOW_SIZE
|
|
||||||
}
|
|
||||||
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
|
|
||||||
}
|
}
|
||||||
val out = dumpOut
|
val out = dumpOut
|
||||||
if (out != null) {
|
if (out != null) {
|
||||||
@@ -151,69 +113,72 @@ class SttStreamer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
|
* Завершить сессию (клик): распознать весь буфер сессии одним вызовом
|
||||||
* распознаваний VAD, затем распознать весь буфер сессии одним вызовом
|
* (хвост [MAX_FULL_BUFFER_SAMPLES], если длиннее) с обрезанием краевой тишины
|
||||||
* (хвост 30 с, если длиннее) — [full] перезаписывается результатом
|
* — [full] перезаписывается результатом. Вернуть полный текст.
|
||||||
* (не конкатенация с VAD-фразами). Вернуть полный текст.
|
* Буферы НЕ сбрасывает — после вызова нужен [reset] (состояние сессии
|
||||||
* Буферы НЕ сбрасывает — после вызова нужно [reset] (состояние
|
* снимает вызывающий).
|
||||||
* сессии снимает вызывающий).
|
|
||||||
*/
|
*/
|
||||||
fun finish(): String {
|
fun finish(): String {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
vad.flush()
|
|
||||||
var seg = vad.popSegment()
|
|
||||||
while (seg != null) {
|
|
||||||
// Копия: массив может переиспользоваться после pop.
|
|
||||||
recognizeSegment(seg.samples.copyOf())
|
|
||||||
seg = vad.popSegment()
|
|
||||||
}
|
|
||||||
val last = lastFuture
|
|
||||||
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
|
|
||||||
recognizeFullBufferLocked()
|
recognizeFullBufferLocked()
|
||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком,
|
* Распознать весь буфер сессии [sessionPcm]: целиком, если ≤
|
||||||
* если ≤ 30 с, иначе последние 30 с (команда перед кликом точно влезает).
|
* [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] сэмплов
|
||||||
* Результат перезаписывает [full] — VAD-фразы идут только в [onPhrase].
|
* (команда перед кликом точно влезает). Перед распознаванием применяется
|
||||||
* Вызывать с удержанным [lock].
|
* [trimEdges], затем [splitPhrases] режет буфер на фразы по паузам — каждая
|
||||||
|
* фраза распознаётся отдельно (whisper принимает волны только ≤ 30 с),
|
||||||
|
* непустые результаты склеиваются через пробел и перезаписывают [full].
|
||||||
|
* Все фразы мусорные → [full] НЕ трогается. Вызывать с удержанным [lock].
|
||||||
*/
|
*/
|
||||||
private fun recognizeFullBufferLocked() {
|
private fun recognizeFullBufferLocked() {
|
||||||
val buffer = sessionPcm
|
val buffer = sessionPcm
|
||||||
if (buffer.isEmpty()) return
|
if (buffer.isEmpty()) return
|
||||||
val chunk = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
|
val base = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
|
||||||
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
|
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
|
||||||
} else buffer
|
} else buffer
|
||||||
|
val chunk = trimEdges(base)
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
lock.withLock { resetLocked() }
|
lock.withLock { resetLocked() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */
|
/** Освободить ресурсы и сторожевой поток (при выключении приложения). */
|
||||||
fun close() {
|
fun close() {
|
||||||
lock.withLock {
|
lock.withLock { deadlineMs = 0 }
|
||||||
deadlineMs = 0
|
|
||||||
vad.close()
|
|
||||||
}
|
|
||||||
runCatching { dumpOut?.close() }
|
runCatching { dumpOut?.close() }
|
||||||
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||||
executor.shutdownNow()
|
executor.shutdownNow()
|
||||||
@@ -221,42 +186,9 @@ class SttStreamer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resetLocked() {
|
private fun resetLocked() {
|
||||||
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
|
|
||||||
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
|
|
||||||
runCatching { vad.flush() }
|
|
||||||
var seg = vad.popSegment()
|
|
||||||
while (seg != null) {
|
|
||||||
seg = vad.popSegment()
|
|
||||||
}
|
|
||||||
session++
|
session++
|
||||||
full = ""
|
full = ""
|
||||||
pending = FloatArray(0)
|
|
||||||
sessionPcm = FloatArray(0)
|
sessionPcm = FloatArray(0)
|
||||||
deadlineMs = 0
|
deadlineMs = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
|
|
||||||
private fun feedWindow(window: FloatArray) {
|
|
||||||
vad.acceptWindow(window)
|
|
||||||
var seg = vad.popSegment()
|
|
||||||
while (seg != null) {
|
|
||||||
// Копия: массив может переиспользоваться после pop.
|
|
||||||
recognizeSegment(seg.samples.copyOf())
|
|
||||||
seg = vad.popSegment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Распознать сегмент в executor-е: обновить full и позвать onPhrase. */
|
|
||||||
private fun recognizeSegment(samples: FloatArray) {
|
|
||||||
val sessionId = session
|
|
||||||
lastFuture = executor.submit {
|
|
||||||
if (sessionId != session) return@submit
|
|
||||||
val phrase = runCatching { stt.recognize(samples) }.getOrDefault("")
|
|
||||||
if (phrase.isBlank()) return@submit
|
|
||||||
val newFull = if (full.isEmpty()) phrase else "$full $phrase"
|
|
||||||
full = newFull
|
|
||||||
deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
|
||||||
onPhrase(phrase, newFull)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@@ -173,7 +207,31 @@ fun ChatScreen() {
|
|||||||
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 +241,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 +268,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 +289,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 +302,74 @@ private fun MessageBubble(role: ChatRole, text: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Элемент ленты: tool-сообщения дополнительно несут имя тула (в БД toolCallId = имя тула). */
|
||||||
|
private data class UiMsg(
|
||||||
|
val role: ChatRole,
|
||||||
|
val content: String,
|
||||||
|
val tool: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun uiMsgOf(m: ChatMessage) = UiMsg(
|
||||||
|
role = m.role,
|
||||||
|
content = m.content,
|
||||||
|
tool = if (m.role == ChatRole.TOOL) m.toolCallId else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Системный блок результата тула (ChatRole.TOOL): свёрнут по умолчанию —
|
||||||
|
* иконка + «тул · имя», тап раскрывает текст результата. Система видна
|
||||||
|
* по требованию, но не шумит в основной переписке.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ToolBubble(msg: UiMsg) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 340.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(PhoneCard)
|
||||||
|
.clickable { expanded = !expanded }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Build,
|
||||||
|
contentDescription = "тул",
|
||||||
|
tint = PhoneMuted,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "тул · ${msg.tool ?: "результат"}",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = if (expanded) "свернуть" else "развернуть",
|
||||||
|
tint = PhoneMuted,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = msg.content,
|
||||||
|
color = PhoneTextDim,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Фиксированный порог ступенчатого свайпа переключения сессий (TZ-ui-four п.3):
|
||||||
|
* жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад.
|
||||||
|
*/
|
||||||
|
private val CHAT_SESSION_SWIPE_THRESHOLD = 120.dp
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ import androidx.compose.ui.unit.sp
|
|||||||
import coil.compose.AsyncImage
|
import 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,177 @@ fun MercuryPanel() {
|
|||||||
Text("☀️ включить экран", fontSize = 12.sp)
|
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 (чистая функция, ветки причины «нет звука») ---
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import pw.binom.viewmate.core.protocol.SttDone
|
|||||||
import pw.binom.viewmate.core.protocol.Welcome
|
import pw.binom.viewmate.core.protocol.Welcome
|
||||||
import pw.binom.viewmate.core.protocol.protocolJson
|
import pw.binom.viewmate.core.protocol.protocolJson
|
||||||
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
|
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
|
||||||
import pw.binom.viewmate.phone.stt.SileroStreamingVad
|
|
||||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||||
import pw.binom.viewmate.phone.stt.WavPcm
|
import pw.binom.viewmate.phone.stt.WavPcm
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
@@ -202,18 +201,17 @@ class GlassesServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): реальный Silero VAD
|
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): весь принятый PCM
|
||||||
* (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель
|
* распознаётся одним вызовом на буфер сессии, фейк-распознаватель
|
||||||
* даёт «привет» на каждый сегмент → очки получают SttDone с непустым текстом.
|
* даёт «привет» → очки получают SttDone с непустым текстом.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
|
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
|
||||||
hub.sttFactory = {
|
hub.sttFactory = {
|
||||||
SttStreamer(
|
SttStreamer(
|
||||||
stt = FakePhraseRecognizer(),
|
stt = FakePhraseRecognizer(),
|
||||||
vad = SileroStreamingVad(),
|
|
||||||
onPhrase = { _, _ -> },
|
|
||||||
onSilence30s = { },
|
onSilence30s = { },
|
||||||
|
onAutoFinished = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||||
|
|||||||
@@ -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 ----------
|
||||||
|
|||||||
@@ -6,42 +6,12 @@ import kotlin.test.assertContentEquals
|
|||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Полный текст по клику (TASK-full-buffer-stt.md): [SttStreamer] накапливает
|
* Полный текст по клику (TZ-STT): [SttStreamer] накапливает весь PCM сессии
|
||||||
* весь PCM сессии независимо от VAD и [SttStreamer.finish] прогоняет буфер
|
* (без VAD) и [SttStreamer.finish] прогоняет буфер (или хвост 120 с) одним
|
||||||
* (или хвост 30 с) одним вызовом [PhraseRecognizer], перезаписывая text.
|
* вызовом [PhraseRecognizer] с обрезанием краевой тишины, перезаписывая text.
|
||||||
* VAD-путь (промежуточные фразы в [SttStreamer.onPhrase]-колбэк) не тронут.
|
|
||||||
*/
|
*/
|
||||||
class FullBufferSttTest {
|
class FullBufferSttTest {
|
||||||
|
|
||||||
/**
|
|
||||||
* Простейший [StreamingVad]: окна копятся, сегментов нет — кроме [flush],
|
|
||||||
* который (если включён) выдаёт один сегмент из всех накопленных окон.
|
|
||||||
*/
|
|
||||||
private class FakeVad(private val segmentOnFlush: Boolean = false) : StreamingVad {
|
|
||||||
override val windowSizeSamples = 512
|
|
||||||
private val windowed = ArrayList<Float>()
|
|
||||||
private var segment: VadSegment? = null
|
|
||||||
|
|
||||||
override fun acceptWindow(window: FloatArray) {
|
|
||||||
for (s in window) windowed += s
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popSegment(): VadSegment? {
|
|
||||||
val s = segment
|
|
||||||
segment = null
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun flush() {
|
|
||||||
if (segmentOnFlush && windowed.isNotEmpty()) {
|
|
||||||
segment = VadSegment(0, windowed.toFloatArray())
|
|
||||||
}
|
|
||||||
windowed.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
|
* Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
|
||||||
* по одному на вызов (последний — на все лишние). [calls] читается
|
* по одному на вызов (последний — на все лишние). [calls] читается
|
||||||
@@ -77,6 +47,12 @@ class FullBufferSttTest {
|
|||||||
private fun expectedFloat(globalIndex: Int): Float =
|
private fun expectedFloat(globalIndex: Int): Float =
|
||||||
(globalIndex % 32768) / 32768f
|
(globalIndex % 32768) / 32768f
|
||||||
|
|
||||||
|
private fun newStreamer(recognizer: RecordingRecognizer) = SttStreamer(
|
||||||
|
stt = recognizer,
|
||||||
|
onSilence30s = { },
|
||||||
|
onAutoFinished = { },
|
||||||
|
)
|
||||||
|
|
||||||
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
|
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
|
||||||
private fun acceptShortSession(streamer: SttStreamer) {
|
private fun acceptShortSession(streamer: SttStreamer) {
|
||||||
var off = 0
|
var off = 0
|
||||||
@@ -90,12 +66,7 @@ class FullBufferSttTest {
|
|||||||
@Test
|
@Test
|
||||||
fun shortBufferIsRecognizedInOneCall() {
|
fun shortBufferIsRecognizedInOneCall() {
|
||||||
val recognizer = RecordingRecognizer("привет")
|
val recognizer = RecordingRecognizer("привет")
|
||||||
val streamer = SttStreamer(
|
val streamer = newStreamer(recognizer)
|
||||||
stt = recognizer,
|
|
||||||
vad = FakeVad(),
|
|
||||||
onPhrase = { _, _ -> },
|
|
||||||
onSilence30s = { },
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
acceptShortSession(streamer)
|
acceptShortSession(streamer)
|
||||||
val full = streamer.finish()
|
val full = streamer.finish()
|
||||||
@@ -108,31 +79,31 @@ class FullBufferSttTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 40 с > 30 с: finish → распознаётся ровно хвост 16000*30 сэмплов, текст не пуст. */
|
/**
|
||||||
|
* 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов (текст не пуст).
|
||||||
|
* Хвост — сплошной зубец без пауз → splitPhrases даёт одну длинную фразу (120 с),
|
||||||
|
* которая режется страховкой на куски ≤ 30 с: 4 вызова по 30 с.
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun longBufferPassesLastThirtySeconds() {
|
fun longBufferPassesLastTwoMinutes() {
|
||||||
val recognizer = RecordingRecognizer("ок")
|
val recognizer = RecordingRecognizer("ок")
|
||||||
val streamer = SttStreamer(
|
val streamer = newStreamer(recognizer)
|
||||||
stt = recognizer,
|
|
||||||
vad = FakeVad(),
|
|
||||||
onPhrase = { _, _ -> },
|
|
||||||
onSilence30s = { },
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
val total = 40 * 16_000
|
val total = 130 * 16_000
|
||||||
var off = 0
|
var off = 0
|
||||||
while (off < total) {
|
while (off < total) {
|
||||||
streamer.accept(pcmChunk(off, 1600))
|
streamer.accept(pcmChunk(off, 1600))
|
||||||
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 * 30, chunk.size, "ровно хвост 30 с")
|
assertEquals(16_000 * 120, sumLen, "в сумме ровно хвост 120 с")
|
||||||
|
recognizer.calls.forEach { assertTrue(it.size <= 30 * 16_000, "каждая фраза ≤ 30 с") }
|
||||||
assertContentEquals(
|
assertContentEquals(
|
||||||
FloatArray(16_000 * 30) { expectedFloat(total - 16_000 * 30 + it) },
|
FloatArray(30 * 16_000) { expectedFloat(total - 16_000 * 120 + it) },
|
||||||
chunk,
|
recognizer.calls[0],
|
||||||
"хвост, а не начало буфера",
|
"первая фраза — начало хвоста, а не начало буфера",
|
||||||
)
|
)
|
||||||
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
|
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,16 +111,33 @@ 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() {
|
||||||
val recognizer = RecordingRecognizer("привет")
|
val recognizer = RecordingRecognizer("привет")
|
||||||
val streamer = SttStreamer(
|
val streamer = newStreamer(recognizer)
|
||||||
stt = recognizer,
|
|
||||||
vad = FakeVad(),
|
|
||||||
onPhrase = { _, _ -> },
|
|
||||||
onSilence30s = { },
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
acceptShortSession(streamer)
|
acceptShortSession(streamer)
|
||||||
streamer.reset()
|
streamer.reset()
|
||||||
@@ -160,34 +148,4 @@ class FullBufferSttTest {
|
|||||||
streamer.close()
|
streamer.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** VAD-фразы по-прежнему бродкастятся; full после finish = результат полного буфера (не конкатенация). */
|
|
||||||
@Test
|
|
||||||
fun vadPhrasesAreBroadcastAndFullIsOverwritten() {
|
|
||||||
val recognizer = RecordingRecognizer("фраза", "весь буфер")
|
|
||||||
val phrases = mutableListOf<Pair<String, String>>()
|
|
||||||
val streamer = SttStreamer(
|
|
||||||
stt = recognizer,
|
|
||||||
vad = FakeVad(segmentOnFlush = true),
|
|
||||||
onPhrase = { phrase, full -> phrases += phrase to full },
|
|
||||||
onSilence30s = { },
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
acceptShortSession(streamer)
|
|
||||||
val full = streamer.finish()
|
|
||||||
|
|
||||||
// Промежуточная VAD-фраза доехала до колбэка.
|
|
||||||
assertEquals(1, phrases.size)
|
|
||||||
assertEquals("фраза", phrases[0].first)
|
|
||||||
|
|
||||||
// Полный прогон: второй вызов на все сессионные 4800 сэмплов.
|
|
||||||
assertEquals(2, recognizer.calls.size)
|
|
||||||
assertEquals(4800, recognizer.calls[1].size)
|
|
||||||
|
|
||||||
// full = результат полного буфера, а не «фраза весь буфер».
|
|
||||||
assertEquals("весь буфер", full)
|
|
||||||
} finally {
|
|
||||||
streamer.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.viewmate.core.stt.splitPhrases
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на
|
||||||
|
* фразы по паузам тишины (≥ 0.6 с), чтобы каждая фраза (whisper ≤ 30 с)
|
||||||
|
* распознавалась отдельно.
|
||||||
|
*
|
||||||
|
* «Речь» — зубчатая волна [0,1) (RMS окна много выше порога 0.005), «тишина» —
|
||||||
|
* нули. Порог/пауза/окно по умолчанию: окно 320 = 20 мс @ 16 кГц, пауза 0.6 с
|
||||||
|
* (25…30 окон), объединение < 1.5 с, страховка ≤ 30 с.
|
||||||
|
*/
|
||||||
|
class PhraseSplitTest {
|
||||||
|
private val win = 320
|
||||||
|
|
||||||
|
/** Зубчатая волна [0,1): «речь» — каждое окно даёт RMS много выше порога. */
|
||||||
|
private fun saw(offset: Int, count: Int): FloatArray =
|
||||||
|
FloatArray(count) { ((offset + it) % 32768) / 32768f }
|
||||||
|
|
||||||
|
private fun silence(count: Int): FloatArray = FloatArray(count)
|
||||||
|
|
||||||
|
private fun concat(vararg parts: FloatArray): FloatArray {
|
||||||
|
var total = 0
|
||||||
|
for (p in parts) total += p.size
|
||||||
|
val out = FloatArray(total)
|
||||||
|
var i = 0
|
||||||
|
for (p in parts) {
|
||||||
|
p.copyInto(out, i)
|
||||||
|
i += p.size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasSound(phrase: FloatArray): Boolean = phrase.any { it > 0.01f }
|
||||||
|
|
||||||
|
/** Две фразы (~2 с), пауза 0.7 с → разделяются; обе содержат речь. */
|
||||||
|
@Test
|
||||||
|
fun twoPhrasesSeparatedByLongPause() {
|
||||||
|
val a = saw(0, 32_000)
|
||||||
|
val pause = silence(11_200) // 0.7 с
|
||||||
|
val b = saw(32_000, 32_000)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(2, phrases.size)
|
||||||
|
phrases.forEach { assertTrue(hasSound(it), "фраза содержит звучный участок") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пауза 0.4 с (< 0.6 с) не разделяет → одна фраза. */
|
||||||
|
@Test
|
||||||
|
fun shortPauseDoesNotSplit() {
|
||||||
|
val a = saw(0, 16_000)
|
||||||
|
val pause = silence(6_400) // 0.4 с
|
||||||
|
val b = saw(16_000, 16_000)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тихие начала слов не режутся: порог 0.005, низкоамплитудные окна не рвут фразу. */
|
||||||
|
@Test
|
||||||
|
fun quietWordStartIsKeptWhole() {
|
||||||
|
// 5 окон почти тишины (ниже порога) + резкий старт → содержимое остаётся одной фразой,
|
||||||
|
// тихие окна начала сохраняются в фразе (не выброшены).
|
||||||
|
val quietHead = silence(5 * win)
|
||||||
|
val loudStart = FloatArray(win) { 0.05f }
|
||||||
|
val speech = saw(0, win * 100) // 100 окон речи (2 с) — не объединяется как обрубок
|
||||||
|
val samples = concat(quietHead, loudStart, speech)
|
||||||
|
val phrases = splitPhrases(samples)
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
assertTrue(phrases[0].size >= (5 + 1 + 100) * win, "тихие окна начала сохранены")
|
||||||
|
assertTrue(hasSound(phrases[0]), "фраза содержит речь")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всё тишина → пустой список. */
|
||||||
|
@Test
|
||||||
|
fun allSilenceReturnsEmpty() {
|
||||||
|
assertEquals(0, splitPhrases(silence(48_000)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустой вход → пустой список. */
|
||||||
|
@Test
|
||||||
|
fun emptyInputReturnsEmpty() {
|
||||||
|
assertEquals(0, splitPhrases(FloatArray(0)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Фраза 31 с → разрезана на куски, каждый ≤ 30 с. */
|
||||||
|
@Test
|
||||||
|
fun phraseLongerThanThirtySecondsIsSplit() {
|
||||||
|
val long = saw(0, 31 * 16_000)
|
||||||
|
val phrases = splitPhrases(long)
|
||||||
|
assertEquals(2, phrases.size)
|
||||||
|
phrases.forEach { assertTrue(it.size <= 30 * 16_000, "кусок ≤ 30 с") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Две короткие фразы (0.8 с + 0.9 с, пауза 0.7 с) объединяются в одну. */
|
||||||
|
@Test
|
||||||
|
fun shortPhrasesAreMerged() {
|
||||||
|
val a = saw(0, 12_800) // 0.8 с = 40 окон (< 75)
|
||||||
|
val pause = silence(11_200)
|
||||||
|
val b = saw(12_800, 14_400) // 0.9 с = 45 окон (< 75)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.phone.stt
|
|||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||||
|
|
||||||
class S16ToFloatTest {
|
class S16ToFloatTest {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import pw.binom.viewmate.core.stt.trimEdges
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
|
||||||
|
* внутренние паузы сохраняются, отступ не выходит за границы.
|
||||||
|
*
|
||||||
|
* Окно 320 сэмплов = 20 мс @ 16 кГц; отступ [padWindows]=15 → 4800 сэмплов = 300 мс.
|
||||||
|
* «Речь» — сэмпл 0.5f (RMS окна 0.5 ≥ порог 0.01), «тишина» — 0.005f (ниже порога).
|
||||||
|
*/
|
||||||
|
class TrimEdgesTest {
|
||||||
|
private val win = 320
|
||||||
|
private val pad = 15 * 320
|
||||||
|
|
||||||
|
/** Массив из [count] сэмплов заданного уровня. */
|
||||||
|
private fun fill(count: Int, level: Float): FloatArray = FloatArray(count) { level }
|
||||||
|
|
||||||
|
private fun concat(vararg parts: FloatArray): FloatArray {
|
||||||
|
var total = 0
|
||||||
|
for (p in parts) total += p.size
|
||||||
|
val out = FloatArray(total)
|
||||||
|
var i = 0
|
||||||
|
for (p in parts) {
|
||||||
|
p.copyInto(out, i)
|
||||||
|
i += p.size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Краевая тишина (100 окон до и после речи в 3 окна) убирается, речь сохраняется. */
|
||||||
|
@Test
|
||||||
|
fun edgeSilenceIsTrimmed() {
|
||||||
|
val lead = fill(100 * win, 0.005f)
|
||||||
|
val voice = fill(3 * win, 0.5f)
|
||||||
|
val tail = fill(100 * win, 0.005f)
|
||||||
|
val samples = concat(lead, voice, tail)
|
||||||
|
|
||||||
|
val res = trimEdges(samples)
|
||||||
|
|
||||||
|
val start = 100 * win - pad
|
||||||
|
val stop = 103 * win + pad
|
||||||
|
assertContentEquals(samples.copyOfRange(start, stop), res, "краевая тишина обрезана, речь сохранена")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Внутренняя пауза (197 окон тишины между двумя речевыми блоками) полностью сохраняется. */
|
||||||
|
@Test
|
||||||
|
fun internalPauseIsPreserved() {
|
||||||
|
val lead = fill(100 * win, 0.005f)
|
||||||
|
val voice1 = fill(3 * win, 0.5f)
|
||||||
|
val pause = fill(197 * win, 0.005f)
|
||||||
|
val voice2 = fill(3 * win, 0.5f)
|
||||||
|
val tail = fill(100 * win, 0.005f)
|
||||||
|
val samples = concat(lead, voice1, pause, voice2, tail)
|
||||||
|
|
||||||
|
val res = trimEdges(samples)
|
||||||
|
|
||||||
|
val start = 100 * win - pad
|
||||||
|
val stop = 303 * win + pad
|
||||||
|
assertContentEquals(samples.copyOfRange(start, stop), res, "внутренняя пауза не выброшена")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустой вход → пустой выход (без ошибок). */
|
||||||
|
@Test
|
||||||
|
fun emptyInputReturnsEmpty() {
|
||||||
|
assertEquals(0, trimEdges(FloatArray(0)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всё-тишина (ниже порога) → пустой массив. */
|
||||||
|
@Test
|
||||||
|
fun allSilenceReturnsEmpty() {
|
||||||
|
val res = trimEdges(FloatArray(100 * win) { 0.005f })
|
||||||
|
assertEquals(0, res.size, "звучных окон нет — результат пуст")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отступ не выходит за границы: речь у краёв короткого буфера → весь массив без исключений. */
|
||||||
|
@Test
|
||||||
|
fun paddingClampsToBounds() {
|
||||||
|
val size = 2000
|
||||||
|
val samples = FloatArray(size)
|
||||||
|
for (i in 0 until win) samples[i] = 0.5f
|
||||||
|
for (i in size - win until size) samples[i] = 0.5f
|
||||||
|
|
||||||
|
val res = trimEdges(samples)
|
||||||
|
|
||||||
|
assertContentEquals(samples.copyOfRange(0, size), res, "clamp к границам, без выхода за массив")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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 с по
|
||||||
|
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||||
|
* < 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
|
||||||
|
}
|
||||||
+175
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user