Compare commits
20 Commits
cf8c5ef604
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a500150d | |||
| f68ea10342 | |||
| 5c86ed00b4 | |||
| 0b3f9ff975 | |||
| 40723fa584 | |||
| 728116662e | |||
| 0cfd92a68f | |||
| f656f43e10 | |||
| 1b28760c6e | |||
| b2e2d2697d | |||
| 92b483a5e3 | |||
| edf7cf63f6 | |||
| d5e60a2565 | |||
| 4587e22b50 | |||
| e18b82642f | |||
| b11879520e | |||
| 3af74851db | |||
| 1aa389a3a0 | |||
| 6cd4a8de43 | |||
| 62d2f06797 |
@@ -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 {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -5,6 +7,20 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
|
||||
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
|
||||
val binomCertsDir: File = File(
|
||||
(project.findProperty("binom.keystore.dir") as? String)
|
||||
?: System.getenv("BINOM_KEYSTORE_DIR")
|
||||
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
|
||||
)
|
||||
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
|
||||
val binomCreds: Map<String, String> =
|
||||
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
|
||||
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
|
||||
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
|
||||
else emptyMap() })
|
||||
|
||||
android {
|
||||
namespace = "pw.binom.viewmate.glasses"
|
||||
compileSdk = 35
|
||||
@@ -17,9 +33,30 @@ android {
|
||||
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
|
||||
// Без кестора binomCreds пусто → release остаётся неподписанным.
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
|
||||
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
|
||||
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
|
||||
signingConfigs {
|
||||
create("binom") {
|
||||
storeFile = binomStoreFile
|
||||
storePassword = binomStorePass
|
||||
keyAlias = binomAlias
|
||||
keyPassword = binomKeyPass
|
||||
enableV1Signing = false
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
signingConfig = signingConfigs.getByName("binom")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +98,11 @@ dependencies {
|
||||
implementation(project(":lib-core"))
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// Локальный Qwen3-ASR на очках (движок + распознавание переезжают на очки из app-phone).
|
||||
// asr-qwen3-android тянет sherpa-onnx (nativ) транзитивно; модель — в filesDir/models
|
||||
// (SttModelProvider), как на телефоне.
|
||||
implementation("pw.binom.asr:asr-qwen3-android:4")
|
||||
|
||||
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
|
||||
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
|
||||
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
|
||||
|
||||
@@ -42,17 +42,19 @@ class BtGlassesTransport(
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* Runtime-пермишены, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12) + RECORD_AUDIO:
|
||||
* микрофон локального STT — без него AudioRecord не стартует и сессия
|
||||
* молча падает в файловую эмуляцию (пустой буфер).
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||
*/
|
||||
fun runtimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
(if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}
|
||||
}) + Manifest.permission.RECORD_AUDIO
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||
|
||||
@@ -4,7 +4,16 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.rayneo.arsdk.android.MercurySDK
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
import pw.binom.viewmate.core.log.FileLogSpool
|
||||
import pw.binom.viewmate.core.log.SystemClock
|
||||
import pw.binom.viewmate.glasses.stt.GlassesAsr
|
||||
import pw.binom.viewmate.glasses.stt.GlassesSttSession
|
||||
import pw.binom.viewmate.glasses.stt.SttModelDownloader
|
||||
|
||||
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||
data class ChatEntry(val role: String, val text: String)
|
||||
@@ -45,6 +54,26 @@ class GlassesApp : Application() {
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
|
||||
/** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */
|
||||
lateinit var sttModel: SttModelDownloader
|
||||
private set
|
||||
|
||||
/** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */
|
||||
lateinit var asr: GlassesAsr
|
||||
private set
|
||||
|
||||
/** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */
|
||||
lateinit var sttSession: GlassesSttSession
|
||||
private set
|
||||
|
||||
|
||||
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
||||
lateinit var logCollector: BatchingLogCollector
|
||||
private set
|
||||
|
||||
/** Скоуп фоновых корутин (таймер лог-коллектора). */
|
||||
private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -89,6 +118,13 @@ class GlassesApp : Application() {
|
||||
*/
|
||||
val answerScrollDelta = MutableStateFlow(0f)
|
||||
|
||||
/**
|
||||
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
||||
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
|
||||
* (видео на очках не играет / данных ещё нет).
|
||||
*/
|
||||
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
|
||||
|
||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||
var mainActivity: MainActivity? = null
|
||||
|
||||
@@ -123,6 +159,35 @@ class GlassesApp : Application() {
|
||||
)
|
||||
hostConnection.start()
|
||||
log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}")
|
||||
|
||||
// Лог-коллектор: пачки → телефон; обрыв связи — спул в filesDir, ретраи таймером.
|
||||
logCollector = BatchingLogCollector(
|
||||
device = "glasses",
|
||||
sink = GlassesLinkLogSink(hostConnection),
|
||||
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 32),
|
||||
clock = SystemClock(),
|
||||
flushIntervalMs = 60_000,
|
||||
maxBatchEntries = 500,
|
||||
)
|
||||
logCollector.start(bgScope)
|
||||
attachLogCollector(logCollector)
|
||||
pw.binom.viewmate.core.logCollector = logCollector
|
||||
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
|
||||
|
||||
// Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range).
|
||||
sttModel = SttModelDownloader(File(filesDir, "models"), bgScope)
|
||||
sttModel.ensure()
|
||||
|
||||
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
|
||||
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity.
|
||||
asr = GlassesAsr(File(filesDir, "models"))
|
||||
sttSession = GlassesSttSession(
|
||||
filesDir = filesDir,
|
||||
scope = bgScope,
|
||||
recognizer = { asr.recognize(it) },
|
||||
modelReady = { sttModel.status.value.ready },
|
||||
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -99,7 +99,8 @@ internal class GlassesStateStore(private val file: File) {
|
||||
|
||||
/**
|
||||
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
|
||||
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part
|
||||
* Каталог: filesDir/viewmate/{itemId}/video.mkv — на очки уходит только видео,
|
||||
* звук (audio-N.ogg) не скачивается, он лежит на телефоне. Скачивание пишется в .part
|
||||
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
|
||||
*/
|
||||
class GlassesDownloadManager(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import pw.binom.viewmate.core.log.LogBatch
|
||||
import pw.binom.viewmate.core.log.LogSink
|
||||
import pw.binom.viewmate.core.protocol.LogBatchMsg
|
||||
|
||||
/**
|
||||
* LogSink коллектора очков: пачка уходит на телефон по связи очков↔телефон
|
||||
* ([LogBatchMsg]). Связи нет → false: пачка прихраняется в споуле очков
|
||||
* (filesDir) и ретраится после восстановления связи.
|
||||
*/
|
||||
class GlassesLinkLogSink(
|
||||
private val connection: HostConnection,
|
||||
) : LogSink {
|
||||
override suspend fun send(batch: LogBatch): Boolean {
|
||||
if (!connection.connected.value) return false
|
||||
connection.sendToHost(LogBatchMsg(entries = batch.entries))
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.net.GlassesTransport
|
||||
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.AudioSyncInfo
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
@@ -101,6 +102,8 @@ class HostConnection(
|
||||
playing = snapshot.playing,
|
||||
itemId = snapshot.itemId,
|
||||
audioIndex = snapshot.audioIndex,
|
||||
sentTs = System.currentTimeMillis(),
|
||||
durationMs = snapshot.durationMs,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -246,9 +249,9 @@ class HostConnection(
|
||||
fun sendPlaybackPositionNow() {
|
||||
if (transport == null) return
|
||||
scope.launch {
|
||||
val (positionMs, playing, itemId, audioIndex) =
|
||||
val (positionMs, playing, itemId, audioIndex, durationMs) =
|
||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis(), durationMs = durationMs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +340,16 @@ class HostConnection(
|
||||
downloadManager.handleDeleteFiles(msg.itemId)
|
||||
}
|
||||
|
||||
is AudioSyncInfo -> {
|
||||
// Индикатор |звук − видео| для статус-панели; active=false (видео не играет) — скрываем.
|
||||
val prev = GlassesApp.instance.audioSyncDiffMs.value
|
||||
GlassesApp.instance.audioSyncDiffMs.value = if (msg.active) msg.diffMs else null
|
||||
// Диагностика: скачок diff (по часам очков) — для корреляции с logcat BT-стека телефона за ту же секунду.
|
||||
if (msg.active && prev != null && msg.diffMs - prev >= 300) {
|
||||
log("audio", "diff скачок $prev→${msg.diffMs} мс")
|
||||
}
|
||||
}
|
||||
|
||||
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
|
||||
|
||||
is ShowText -> {
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
/** Единый лог очков: простой println (работает и в unit-тестах). */
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог очков: println (видно в юнит-тестах) + лог-коллектор — пачки
|
||||
* уходят на телефон ([GlassesLinkLogSink]); при обрыве связи пачки
|
||||
* прихраняются в споуле очков (filesDir) и ретраятся позже.
|
||||
*/
|
||||
@Volatile
|
||||
private var logCollector: BatchingLogCollector? = null
|
||||
|
||||
/** Подключить коллектор (GlassesApp.onCreate, после HostConnection). */
|
||||
fun attachLogCollector(collector: BatchingLogCollector) {
|
||||
logCollector = collector
|
||||
}
|
||||
|
||||
fun log(tag: String, message: String) {
|
||||
println("[view-mate] $tag $message")
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -48,15 +48,13 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.core.protocol.SttText
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
||||
import pw.binom.viewmate.glasses.gestures.actionName
|
||||
import pw.binom.viewmate.glasses.gestures.assistantMode
|
||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
||||
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||
import pw.binom.viewmate.glasses.ui.Binocular
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||
@@ -70,11 +68,6 @@ import pw.binom.viewmate.glasses.ui.panelOffsetX
|
||||
class MainActivity : BaseEventActivity() {
|
||||
private var resumeAfterScreenOn = false
|
||||
|
||||
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
|
||||
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
|
||||
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
|
||||
}
|
||||
|
||||
/**
|
||||
* DEBUG-имитация жестов (эмулятор без трекпада):
|
||||
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
|
||||
@@ -265,7 +258,7 @@ class MainActivity : BaseEventActivity() {
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/** Click (кино): плей/пауза — как сегодня. */
|
||||
@@ -276,15 +269,13 @@ class MainActivity : BaseEventActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
|
||||
private fun sendPhrase() {
|
||||
log("gesture", "Click → отправить фразу")
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.listening.value = false
|
||||
GlassesApp.instance.thinking.value = true
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||
}
|
||||
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = true
|
||||
app.sttSession.send()
|
||||
}
|
||||
|
||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||
@@ -296,7 +287,7 @@ class MainActivity : BaseEventActivity() {
|
||||
app.thinking.value = false
|
||||
app.answerScrollDelta.value = 0f
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,7 +299,7 @@ class MainActivity : BaseEventActivity() {
|
||||
val app = GlassesApp.instance
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||
}
|
||||
|
||||
@@ -321,13 +312,38 @@ class MainActivity : BaseEventActivity() {
|
||||
private fun cancelDictation() {
|
||||
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
|
||||
val app = GlassesApp.instance
|
||||
sttMic.stop()
|
||||
app.sttSession.cancel()
|
||||
app.sttText.value = ""
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,10 +356,10 @@ class MainActivity : BaseEventActivity() {
|
||||
*/
|
||||
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||
|
||||
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
||||
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
|
||||
private fun finishAssistantMode(reason: String) {
|
||||
log("gesture", reason)
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.sttSession.stop()
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = false
|
||||
|
||||
@@ -70,6 +70,8 @@ internal data class PlaybackSnapshot(
|
||||
val playing: Boolean,
|
||||
val itemId: String?,
|
||||
val audioIndex: Int,
|
||||
/** Длительность видео, мс; -1 = неизвестно (плеера нет / ещё не prepared). */
|
||||
val durationMs: Long = -1L,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -239,10 +241,10 @@ class MovieController(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */
|
||||
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex, длительность). Только с главного потока. */
|
||||
internal fun playbackPosition(): PlaybackSnapshot {
|
||||
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex)
|
||||
return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex)
|
||||
return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex, player.duration)
|
||||
}
|
||||
|
||||
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
||||
|
||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses.gestures
|
||||
|
||||
import com.rayneo.arsdk.android.touch.TempleAction
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||
|
||||
/**
|
||||
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||
@@ -159,6 +160,19 @@ fun overlayHeader(state: AssistantState): String? = when {
|
||||
else -> state.sttText.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка статуса модели Qwen3 для оверлея (показывается, когда модель не READY и
|
||||
* микрофон открыт — распознавание гарантированно пустое): null — модель готова
|
||||
* (ничего не показывать); DOWNLOADING — «качается N%»; MISSING — не скачана;
|
||||
* FAILED — не скачалась (пойдут авто-повторы). Чистая функция — юнит-тесты без Android.
|
||||
*/
|
||||
fun modelStatusLine(status: SttModelStatus): String? = when (status.phase) {
|
||||
SttModelStatus.Phase.READY -> null
|
||||
SttModelStatus.Phase.DOWNLOADING -> "модель качается: ${status.percent}%"
|
||||
SttModelStatus.Phase.MISSING -> "модель не скачана — распознавание недоступно"
|
||||
SttModelStatus.Phase.FAILED -> "модель не скачалась — повторю автоматически"
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
|
||||
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -18,6 +19,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pw.binom.viewmate.glasses.GlassesApp
|
||||
|
||||
/**
|
||||
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
|
||||
@@ -36,12 +38,20 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
|
||||
val d = data ?: return
|
||||
val baseStyle = MaterialTheme.typography.bodyMedium
|
||||
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
|
||||
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
|
||||
// + свежее значение от телефона); без видео — ничего не выводить.
|
||||
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
|
||||
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState()
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(Color(0x99000000)) // полупрозрачный чёрный
|
||||
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (movieActive.value && diffMs.value != null) {
|
||||
Text("${diffMs.value} мс", color = Color(0xFF999999), style = style)
|
||||
Spacer(Modifier.width(6.dp * scale))
|
||||
}
|
||||
Text(d.time, color = Color.White, style = style)
|
||||
Spacer(Modifier.width(6.dp * scale))
|
||||
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import pw.binom.asr.qwen3.Qwen3Config
|
||||
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/**
|
||||
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
|
||||
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
|
||||
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
|
||||
* [SttModelDownloader]).
|
||||
*/
|
||||
class GlassesAsr(
|
||||
modelsDir: File,
|
||||
numThreads: Int = 4,
|
||||
) {
|
||||
private val modelsDir = modelsDir
|
||||
private val numThreads = numThreads
|
||||
|
||||
/**
|
||||
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
|
||||
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
|
||||
*/
|
||||
private val recognizer by lazy {
|
||||
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
|
||||
createQwen3Sherpa(
|
||||
Qwen3Config(
|
||||
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
|
||||
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
|
||||
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
|
||||
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
|
||||
numThreads = numThreads,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
||||
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
|
||||
recognizer.recognize(samples, sampleRate)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
import pw.binom.viewmate.core.stt.splitPhrases
|
||||
import pw.binom.viewmate.core.stt.trimEdges
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
|
||||
interface GlassesMic {
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean)
|
||||
fun stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
||||
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
||||
*
|
||||
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
||||
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
|
||||
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
|
||||
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
|
||||
* пусто после 30s-лимита (timeout) — автозавершение.
|
||||
*
|
||||
* @param onResult результат распознавания: text — фактический текст (может быть
|
||||
* пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout).
|
||||
*/
|
||||
class GlassesSttSession(
|
||||
private val filesDir: File,
|
||||
private val scope: CoroutineScope,
|
||||
private val recognizer: (FloatArray) -> String,
|
||||
private val modelReady: () -> Boolean,
|
||||
private val onResult: (text: String, auto: Boolean) -> Unit,
|
||||
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
|
||||
) {
|
||||
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
|
||||
private val sessionLimitMs = 30_000L
|
||||
|
||||
private var mic: GlassesMic? = null
|
||||
|
||||
/**
|
||||
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
|
||||
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
|
||||
*/
|
||||
private val pcmLock = ReentrantLock()
|
||||
private var pcm = ByteArray(0)
|
||||
private var watchdog: Job? = null
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
val isRunning: Boolean get() = running
|
||||
|
||||
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
||||
mic = m
|
||||
m.start(filesDir) { running }
|
||||
watchdog = scope.launch {
|
||||
delay(sessionLimitMs)
|
||||
finish(auto = true)
|
||||
}
|
||||
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
|
||||
}
|
||||
|
||||
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
|
||||
fun send() = finish(auto = false)
|
||||
|
||||
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
|
||||
fun cancel() {
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
log("stt", "STT-сессия очков: отмена")
|
||||
}
|
||||
|
||||
/** Полный стоп (выход из режима ассистента). */
|
||||
fun stop() {
|
||||
running = false
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
log("stt", "STT-сессия очков: закрыта")
|
||||
}
|
||||
|
||||
private fun finish(auto: Boolean) {
|
||||
if (!running) return
|
||||
running = false
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val text = if (audio.isNotEmpty() && modelReady()) {
|
||||
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
|
||||
phrases.joinToString(" ") { recognizer(it) }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
withContext(Dispatchers.Main) { onResult(text, auto) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopMic() {
|
||||
mic?.stop()
|
||||
mic = null
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,13 @@ import kotlin.math.roundToInt
|
||||
class SttMicStream(
|
||||
private val scope: CoroutineScope,
|
||||
private val onChunk: suspend (ByteArray) -> Unit,
|
||||
) {
|
||||
) : GlassesMic {
|
||||
private var job: Job? = null
|
||||
|
||||
/** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */
|
||||
private var dump: WavWriter? = null
|
||||
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
override fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
job?.cancel()
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
@@ -96,7 +96,7 @@ class SttMicStream(
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
override fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Модель Qwen3-ASR 0.6B int8 для очек: 4 компонента в [filesDir]/models
|
||||
* (та же иерархия, что на телефоне — `PhoneApp.ensureStt`: conv/encoder/decoder +
|
||||
* tokenizer/). Скачиваются с зеркала [SttModel.BASE_URL] при отсутствии локально:
|
||||
* докачка с места обрыва (Range), проверка SHA-256, сквозной прогресс.
|
||||
*
|
||||
* Зеркало — внутренний `static.binom.pw/models/qwen3_06/` (обычная иерархия
|
||||
* корневых .onnx + подкаталог tokenizer/), URL зашит константой.
|
||||
*/
|
||||
object SttModel {
|
||||
/** Корень зеркала модели: обычные имена файлов + подкаталог tokenizer/. */
|
||||
const val BASE_URL = "https://static.binom.pw/models/qwen3_06"
|
||||
|
||||
/** Иерархия модели + размер и SHA-256 каждого файла (контроль целостности). */
|
||||
internal val FILES: List<ModelFile> = listOf(
|
||||
ModelFile("conv_frontend.onnx", 44_148_281L, "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"),
|
||||
ModelFile("encoder.int8.onnx", 182_491_662L, "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"),
|
||||
ModelFile("decoder.int8.onnx", 755_914_231L, "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"),
|
||||
ModelFile("tokenizer/merges.txt", 1_671_853L, "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"),
|
||||
ModelFile("tokenizer/vocab.json", 2_776_833L, "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"),
|
||||
ModelFile("tokenizer/tokenizer_config.json", 12_487L, "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Один файл модели: относительный путь (в models/), размер и SHA-256. */
|
||||
internal data class ModelFile(
|
||||
val relPath: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256Hex: String,
|
||||
)
|
||||
|
||||
/** Быстрый «довольны ли файлом»: есть + размер совпадает (SHA проверяем на докатке). */
|
||||
internal fun isSatisfied(modelsDir: File, f: ModelFile): Boolean {
|
||||
val final = File(modelsDir, f.relPath)
|
||||
return final.isFile && final.length() == f.sizeBytes
|
||||
}
|
||||
|
||||
/** Какие файлы [files] нужны (отсутствуют или битые по размеру). */
|
||||
internal fun whichFilesNeeded(modelsDir: File, files: List<ModelFile>): List<ModelFile> =
|
||||
files.filter { !isSatisfied(modelsDir, it) }
|
||||
|
||||
/** Модель на диске полностью: все файлы [files] на месте с верным размером. */
|
||||
internal fun isModelComplete(modelsDir: File, files: List<ModelFile> = SttModel.FILES): Boolean =
|
||||
whichFilesNeeded(modelsDir, files).isEmpty()
|
||||
|
||||
/** Все байты всех файлов модели (для сквозного процента). */
|
||||
internal fun modelTotalBytes(files: List<ModelFile> = SttModel.FILES): Long = files.sumOf { it.sizeBytes }
|
||||
|
||||
/** SHA-256 файла (потоком, без загрузки всего в память). */
|
||||
internal fun sha256Hex(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
digest.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
/** Статус модели: что происходит со скачиванием (для индикатора в UI очков). */
|
||||
data class SttModelStatus(
|
||||
val phase: Phase,
|
||||
val percent: Int = 0,
|
||||
val detail: String = "",
|
||||
) {
|
||||
enum class Phase { MISSING, DOWNLOADING, READY, FAILED }
|
||||
|
||||
val ready: Boolean get() = phase == Phase.READY
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание модели Qwen3-ASR в [modelsDir] (filesDir/models). Прогресс — в [status].
|
||||
* Идиомпотентно: [ensure] не делает ничего, если модель уже на месте; иначе докачивает
|
||||
* недостающие/битые файлы с Range и проверяет SHA-256.
|
||||
*/
|
||||
class SttModelDownloader(
|
||||
private val modelsDir: File,
|
||||
private val scope: CoroutineScope,
|
||||
private val okHttp: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // большие файлы, паузы в потоке
|
||||
.build(),
|
||||
) {
|
||||
private val _status = MutableStateFlow(SttModelStatus(SttModelStatus.Phase.MISSING))
|
||||
val status: StateFlow<SttModelStatus> = _status.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var downloadJob: Job? = null
|
||||
|
||||
/** Таймер авто-повтора после ошибки (сеть могла появиться позже — см. [scheduleRetry]). */
|
||||
@Volatile
|
||||
private var retryJob: Job? = null
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Пауза до авто-повтора скачивания после FAILED: сеть могла появиться позже. */
|
||||
private const val RETRY_DELAY_MS = 15_000L
|
||||
}
|
||||
|
||||
/** Перечитать с диска: READY (готово) или MISSING (не хватает). */
|
||||
fun refresh() {
|
||||
_status.value = if (isModelComplete(modelsDir)) {
|
||||
SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||
} else {
|
||||
SttModelStatus(SttModelStatus.Phase.MISSING, overallPercent(), "нет локально — скачаю при старте")
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачать недостающее (idempotent; повторный вызов пока идёт — игнор). */
|
||||
fun ensure() {
|
||||
if (isModelComplete(modelsDir)) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
if (downloadJob?.isActive == true) return
|
||||
downloadJob = scope.launch {
|
||||
try {
|
||||
val total = modelTotalBytes()
|
||||
val needed = whichFilesNeeded(modelsDir, SttModel.FILES)
|
||||
var done = total - needed.sumOf { it.sizeBytes } // байты уже готовых файлов
|
||||
needed.forEach { f ->
|
||||
currentCoroutineContext().ensureActive()
|
||||
downloadOne(f) { fileDone ->
|
||||
val overall = ((done + fileDone) * 100 / total).toInt().coerceIn(0, 100)
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.DOWNLOADING, overall, "${f.relPath}: $overall%")
|
||||
}
|
||||
done += f.sizeBytes
|
||||
log("stt", "модель: скачан ${f.relPath} (${f.sizeBytes} Б)")
|
||||
}
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||
log("stt", "модель Qwen3-ASR готова в ${modelsDir.absolutePath}")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("stt", "ошибка скачивания модели: ${e.message}")
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.FAILED, overallPercent(), "ошибка: ${e.message}")
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-повтор после [SttModelStatus.Phase.FAILED]: сеть могла появиться позже
|
||||
* (Wi-Fi очков выключен при старте, DNS не резолвится — фиксированные случаи).
|
||||
* Один таймер: новый провал перезапускает его; при готовности/идущем скачивании
|
||||
* [ensure] — no-op, так что повторы сами завершаются.
|
||||
*/
|
||||
private fun scheduleRetry() {
|
||||
retryJob?.cancel()
|
||||
retryJob = scope.launch {
|
||||
delay(RETRY_DELAY_MS)
|
||||
log("stt", "модель: авто-повтор скачивания (ошибка могла быть сессионной — сеть)")
|
||||
ensure()
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачать один файл: Range-докачка в .part, SHA-256, rename в финальный. onProgress — байты файла. */
|
||||
private suspend fun downloadOne(f: ModelFile, onProgress: (Long) -> Unit) {
|
||||
val parent = File(modelsDir, f.relPath).parentFile
|
||||
parent?.mkdirs()
|
||||
val final = File(modelsDir, f.relPath)
|
||||
val part = File(modelsDir, "$f.relPath.part")
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
|
||||
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
|
||||
if (existing > 0) builder.header("Range", "bytes=$existing-")
|
||||
|
||||
okHttp.newCall(builder.build()).execute().use { response ->
|
||||
val code = response.code
|
||||
if (code != 200 && code != 206) error("HTTP $code для ${f.relPath}")
|
||||
val body = response.body
|
||||
|
||||
val resumed = code == 206 && existing > 0
|
||||
val input = body.byteStream()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
var done = if (resumed) existing else 0L
|
||||
onProgress(done)
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
onProgress(done)
|
||||
}
|
||||
}
|
||||
|
||||
if (sha256Hex(part) != f.sha256Hex) {
|
||||
part.delete()
|
||||
error("SHA-256 не совпал для ${f.relPath}")
|
||||
}
|
||||
if (final.exists()) final.delete()
|
||||
if (!part.renameTo(final)) {
|
||||
part.copyTo(final, overwrite = true)
|
||||
part.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сквозной процент: сколько всех байтов модели уже на диске (по размеру). */
|
||||
private fun overallPercent(): Int {
|
||||
val total = modelTotalBytes()
|
||||
if (total <= 0) return 0
|
||||
val have = SttModel.FILES.filter { isSatisfied(modelsDir, it) }.sumOf { it.sizeBytes }
|
||||
return (have * 100 / total).toInt().coerceIn(0, 100)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import pw.binom.viewmate.glasses.ChatEntry
|
||||
import pw.binom.viewmate.glasses.GlassesApp
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||
import pw.binom.viewmate.glasses.gestures.modelStatusLine
|
||||
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||
|
||||
@@ -55,6 +56,10 @@ fun AssistantOverlay() {
|
||||
val chatHistory by app.chatHistory.collectAsState()
|
||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||
if (!overlayVisible(state)) return
|
||||
val modelStatus by app.sttModel.status.collectAsState()
|
||||
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
|
||||
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
|
||||
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null
|
||||
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||
@@ -81,7 +86,7 @@ fun AssistantOverlay() {
|
||||
}
|
||||
}
|
||||
|
||||
overlayBox(app, state, chatHistory, scrollState)
|
||||
overlayBox(app, state, chatHistory, scrollState, modelLine)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +102,7 @@ private fun overlayBox(
|
||||
state: AssistantState,
|
||||
chatHistory: List<ChatEntry>,
|
||||
scrollState: ScrollState,
|
||||
modelLine: String?,
|
||||
) {
|
||||
Binocular(scale = false) {
|
||||
val scale by app.scale.collectAsState()
|
||||
@@ -119,7 +125,7 @@ private fun overlayBox(
|
||||
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
||||
),
|
||||
) {
|
||||
AssistantOverlayContent(state, chatHistory, scrollState)
|
||||
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
|
||||
|
||||
if (state.listening) {
|
||||
Box(
|
||||
@@ -140,6 +146,7 @@ private fun AssistantOverlayContent(
|
||||
state: AssistantState,
|
||||
chatHistory: List<ChatEntry>,
|
||||
scrollState: ScrollState,
|
||||
modelLine: String?,
|
||||
) {
|
||||
val hasHistory = chatHistory.isNotEmpty()
|
||||
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
||||
@@ -160,6 +167,17 @@ private fun AssistantOverlayContent(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
// Строка статуса модели (только «слушаю» + модель не READY) — объясняет, почему
|
||||
// распознавание сейчас не работает и что происходит (качается / не скачалась).
|
||||
if (!modelLine.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = modelLine,
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
if (hasHistory) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(
|
||||
|
||||
+28
@@ -15,6 +15,7 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||
|
||||
/**
|
||||
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
||||
@@ -227,6 +228,33 @@ class AssistantGesturesTest {
|
||||
assertNull(overlayHeader(movie))
|
||||
}
|
||||
|
||||
// ————— строка статуса модели —————
|
||||
|
||||
@Test
|
||||
fun modelStatusLineReadyIsNull() {
|
||||
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelStatusLineDownloadingShowsPercent() {
|
||||
assertEquals(
|
||||
"модель качается: 45%",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelStatusLineMissingAndFailed() {
|
||||
assertEquals(
|
||||
"модель не скачана — распознавание недоступно",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
|
||||
)
|
||||
assertEquals(
|
||||
"модель не скачалась — повторю автоматически",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
|
||||
)
|
||||
}
|
||||
|
||||
// ————— скролл ответа —————
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,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.io.File
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@@ -7,6 +8,20 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
|
||||
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
|
||||
val binomCertsDir: File = File(
|
||||
(project.findProperty("binom.keystore.dir") as? String)
|
||||
?: System.getenv("BINOM_KEYSTORE_DIR")
|
||||
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
|
||||
)
|
||||
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
|
||||
val binomCreds: Map<String, String> =
|
||||
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
|
||||
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
|
||||
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
|
||||
else emptyMap() })
|
||||
|
||||
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
|
||||
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
|
||||
val llmApiKey: String = run {
|
||||
@@ -47,9 +62,30 @@ android {
|
||||
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
|
||||
}
|
||||
|
||||
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
|
||||
// Без кестора binomCreds пусто → release остаётся неподписанным.
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
|
||||
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
|
||||
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
|
||||
signingConfigs {
|
||||
create("binom") {
|
||||
storeFile = binomStoreFile
|
||||
storePassword = binomStorePass
|
||||
keyAlias = binomAlias
|
||||
keyPassword = binomKeyPass
|
||||
enableV1Signing = false
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (binomCreds.isNotEmpty()) {
|
||||
signingConfig = signingConfigs.getByName("binom")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,16 +133,25 @@ dependencies {
|
||||
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
|
||||
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
|
||||
|
||||
// QR-код для паринга очков (pairCode) — рендер BitMatrix->Bitmap
|
||||
implementation("com.google.zxing:core:3.5.2")
|
||||
|
||||
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||
implementation(libs.koog.openai.client)
|
||||
implementation(libs.koog.http.client.okhttp)
|
||||
|
||||
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU)
|
||||
implementation(libs.litertlm.android)
|
||||
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU):
|
||||
// KMP-обёртка pw.binom.litert (универсальный контракт RAW-инференса + google-бэкенд)
|
||||
implementation(libs.litert.google)
|
||||
// универсальный контракт (LiteLlm/LiteRequest…) — импортируется напрямую
|
||||
implementation(libs.litert.api)
|
||||
|
||||
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
|
||||
// Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), JitPack
|
||||
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
|
||||
|
||||
// Офлайн-распознавание Qwen3-ASR (asr-kmp, caffeine nexus) — Qwen3AsrStt
|
||||
implementation("pw.binom.asr:asr-qwen3-android:4")
|
||||
|
||||
// WS-сервер телефона (CIO — работает на Android)
|
||||
implementation("io.ktor:ktor-server-core:3.3.0")
|
||||
implementation("io.ktor:ktor-server-cio:3.3.0")
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -20,34 +22,66 @@ import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */
|
||||
/** Решение одного sync-цикла: speed — желаемая скорость аудио; seekToMs не null → редкий жёсткий seek; playing не null → play/pause. */
|
||||
internal data class SyncDecision(
|
||||
val speed: Float = 1f,
|
||||
val seekToMs: Long? = null,
|
||||
val playing: Boolean? = null,
|
||||
)
|
||||
|
||||
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */
|
||||
internal const val SYNC_THRESHOLD_MS = 1000L
|
||||
/** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
|
||||
internal const val TRIGGER_BAND_MS = 150L
|
||||
|
||||
/**
|
||||
* Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f).
|
||||
* 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED].
|
||||
*/
|
||||
internal const val DRIFT_GAIN = 0.0002f
|
||||
|
||||
/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */
|
||||
internal const val MAX_DRIFT_SPEED = 0.10f
|
||||
|
||||
/**
|
||||
* Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo.
|
||||
* Поднят с 2000 до 5000: обычная буферная пауза вывода (0.5–1.5 с) при локальном файле не должна
|
||||
* форсировать жёсткий seek (он перебуферизирует ExoPlayer и даёт слышимую тишину + прыжок позиции);
|
||||
* умеренный рассинхрон закрывается плавной коррекцией скоростью, бесшовно.
|
||||
*/
|
||||
internal const val HARD_SEEK_THRESHOLD_MS = 5000L
|
||||
|
||||
/** EWMA-сглаживание измеренной латентности линка (доля старого значения). */
|
||||
internal const val LINK_LATENCY_ALPHA = 0.8
|
||||
/** Верхний предел измеренной латентности: выше — артефакт (обрыв/зависание), игнорируем. */
|
||||
internal const val LINK_LATENCY_MAX_MS = 2000.0
|
||||
|
||||
/**
|
||||
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
|
||||
* - |audio - video| > [thresholdMs] → перемотать на позицию очков;
|
||||
* - isPlaying очков отличается от состояния аудио → play/pause.
|
||||
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
|
||||
* - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем),
|
||||
* звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается
|
||||
* [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно;
|
||||
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
|
||||
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
|
||||
*/
|
||||
internal fun decideSync(
|
||||
audioPosition: Long,
|
||||
videoPosition: Long,
|
||||
audioPlaying: Boolean,
|
||||
videoPlaying: Boolean,
|
||||
thresholdMs: Long = SYNC_THRESHOLD_MS,
|
||||
): SyncDecision {
|
||||
val seekToMs = if (abs(audioPosition - videoPosition) > thresholdMs) videoPosition else null
|
||||
val diff = audioPosition - videoPosition
|
||||
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
|
||||
val speed = when {
|
||||
seekToMs != null -> 1f
|
||||
abs(diff) <= TRIGGER_BAND_MS -> 1f
|
||||
else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED)
|
||||
}
|
||||
val playing = when {
|
||||
videoPlaying == audioPlaying -> null
|
||||
videoPlaying -> true
|
||||
else -> false
|
||||
}
|
||||
return SyncDecision(seekToMs = seekToMs, playing = playing)
|
||||
return SyncDecision(speed = speed, seekToMs = seekToMs, playing = playing)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,15 +149,51 @@ class AudioSyncPlayer(private val context: Context) {
|
||||
private var player: ExoPlayer? = null
|
||||
private var ticker: Job? = null
|
||||
|
||||
/** Позиция очков из последнего sync (для догонки после неточного seekTo). */
|
||||
/** Слушатель буферизации ExoPlayer: логирует паузы вывода с длительностью (причина «немого» звука:
|
||||
* длительная буферизация = локальная/S3-дорога, короткая/абсолютное отсутствие = BT-стек). */
|
||||
private val bufferingListener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
bufferingStartMs = System.currentTimeMillis()
|
||||
log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)")
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
val bufferedMs = if (bufferingStartMs > 0L) System.currentTimeMillis() - bufferingStartMs else 0L
|
||||
bufferingStartMs = 0L
|
||||
log("audio", "буфер готов, вывод возобновлён${if (bufferedMs > 0L) " (буферизация ${bufferedMs} мс)" else ""}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Позиция очков из последнего sync. */
|
||||
private var lastVideoPositionMs = 0L
|
||||
/** IsPlaying очков из последнего sync. */
|
||||
private var lastVideoPlaying = false
|
||||
/** Сглаженная (EWMA) латентность линка очки→телефон (мс): запаздывание мастер-позиции. */
|
||||
private var linkLatencyMs = 0.0
|
||||
private var lastLatencyLoggedAt = 0L
|
||||
|
||||
/** Текущий источник для логов: «стриминг S3» или «локальный файл» — виден в каждой периодической
|
||||
* строке «audio sync:», чтобы любой фрагмент logcat отвечал на вопрос «стримлю или скачанное?». */
|
||||
private var sourceLabel = "нет"
|
||||
|
||||
/** Момент входа в STATE_BUFFERING (для лога длительности; 0 — не в буферизации). */
|
||||
private var bufferingStartMs = 0L
|
||||
|
||||
private val _positionMs = MutableStateFlow(0L)
|
||||
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
|
||||
val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
|
||||
|
||||
private val _diffMs = MutableStateFlow(0L)
|
||||
/** |звук − видео| из последнего sync-цикла (мс), для индикатора статус-панели очков. 0 — синхронизации не было. */
|
||||
val diffMs: StateFlow<Long> = _diffMs.asStateFlow()
|
||||
|
||||
/** Играет ли видео на очках (по последнему PlaybackPosition). Только с главного потока. */
|
||||
val videoPlaying: Boolean
|
||||
get() = lastVideoPlaying
|
||||
|
||||
/** Есть ли активный аудио-плеер (просмотр на очках). */
|
||||
val isActive: Boolean
|
||||
get() = player != null
|
||||
@@ -139,43 +209,62 @@ class AudioSyncPlayer(private val context: Context) {
|
||||
} else {
|
||||
Uri.fromFile(File(urlOrFile))
|
||||
}
|
||||
val p = player ?: ExoPlayer.Builder(context).build().also { player = it }
|
||||
val p = player ?: ExoPlayer.Builder(context).build().also {
|
||||
it.addListener(bufferingListener)
|
||||
player = it
|
||||
}
|
||||
p.setMediaItem(MediaItem.fromUri(uri))
|
||||
p.prepare()
|
||||
p.seekTo(startPositionMs.coerceAtLeast(0L))
|
||||
p.playWhenReady = true
|
||||
log("audio", "играю $urlOrFile с ${startPositionMs} мс")
|
||||
val isStream = urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://")
|
||||
sourceLabel = if (isStream) "стриминг S3" else "локальный файл"
|
||||
log("audio", "играю $sourceLabel: $urlOrFile с ${startPositionMs} мс")
|
||||
startTicker()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Коррекция по позиции очков: diff > 1000 мс → seek, play/pause по isPlaying.
|
||||
* Пишет ключевой лог для QA при каждом вызове.
|
||||
* Коррекция по позиции очков: за пределами гистерезис-зоны скорость плавно
|
||||
* подгоняется через setPlaybackParameters (без seekTo); жёсткий seek — только
|
||||
* при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f.
|
||||
* Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове.
|
||||
*/
|
||||
fun sync(positionMs: Long, isPlaying: Boolean) {
|
||||
fun sync(positionMs: Long, isPlaying: Boolean, sentTs: Long = 0L) {
|
||||
main.post {
|
||||
val p = player ?: return@post
|
||||
lastVideoPositionMs = positionMs
|
||||
lastVideoPlaying = isPlaying
|
||||
val audioPos = p.currentPosition
|
||||
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying)
|
||||
decision.seekToMs?.let {
|
||||
p.seekTo(it)
|
||||
lastVideoPositionMs = it
|
||||
// Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с),
|
||||
// второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с).
|
||||
main.postDelayed({
|
||||
val pp = player ?: return@postDelayed
|
||||
val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду
|
||||
if (abs(pp.currentPosition - target) > 500) {
|
||||
pp.seekTo(target)
|
||||
log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})")
|
||||
// Латентность линка очки→телефон: сколько мс прошло от отправки позиции на очках до обработки здесь.
|
||||
// Часы синхронизированы через mercury.syncTime(), так что это чистая транспортная задержка.
|
||||
if (sentTs > 0L) {
|
||||
val measured = (System.currentTimeMillis() - sentTs).toDouble()
|
||||
if (measured in 0.0..LINK_LATENCY_MAX_MS) {
|
||||
linkLatencyMs = linkLatencyMs * LINK_LATENCY_ALPHA + measured * (1 - LINK_LATENCY_ALPHA)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastLatencyLoggedAt > 5000) {
|
||||
lastLatencyLoggedAt = now
|
||||
log("audio sync:", "латентность линка: ${linkLatencyMs.toInt()} мс")
|
||||
}
|
||||
}, 1_000)
|
||||
}
|
||||
}
|
||||
// Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел).
|
||||
val videoPos = positionMs + linkLatencyMs.toLong()
|
||||
val audioPos = p.currentPosition
|
||||
_diffMs.value = abs(audioPos - videoPos)
|
||||
val decision = decideSync(audioPos, videoPos, p.isPlaying, isPlaying)
|
||||
when {
|
||||
decision.seekToMs != null -> {
|
||||
p.seekTo(decision.seekToMs)
|
||||
p.setPlaybackParameters(PlaybackParameters(1f))
|
||||
log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)} src=$sourceLabel)")
|
||||
}
|
||||
else -> {
|
||||
p.setPlaybackParameters(PlaybackParameters(decision.speed))
|
||||
log("audio sync:", "video=$videoPos audio=$audioPos diff=${abs(audioPos - videoPos)} speed=${decision.speed} src=$sourceLabel")
|
||||
}
|
||||
}
|
||||
decision.playing?.let { if (it) p.play() else p.pause() }
|
||||
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +287,10 @@ class AudioSyncPlayer(private val context: Context) {
|
||||
ticker = null
|
||||
val p = player
|
||||
player = null
|
||||
lastVideoPlaying = false
|
||||
sourceLabel = "нет"
|
||||
_positionMs.value = 0L
|
||||
_diffMs.value = 0L
|
||||
if (p != null) {
|
||||
p.playWhenReady = false
|
||||
p.stop()
|
||||
|
||||
@@ -44,12 +44,17 @@ class BtServerTransport(
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для сканирования; ADVERTISE — для GATT-сервера
|
||||
* очков, Mercury-управление),
|
||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||
*/
|
||||
fun runtimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import com.google.ai.edge.litertlm.Backend
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||
import java.io.BufferedReader
|
||||
@@ -122,9 +121,62 @@ object DebugHttpServer {
|
||||
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
|
||||
method == "GET" && path.startsWith("/mercury/status") -> {
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"}}"""
|
||||
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"},"pairing":${s.pairCode != null},"pairCode":${s.pairCode?.let { "\"$it\"" } ?: "null"},"realAddress":${s.realAddress?.let { "\"$it\"" } ?: "null"}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "GET" && path.startsWith("/mercury/bonded") -> {
|
||||
val bonds = MercuryBridge.bondedAddresses()
|
||||
val items = bonds.joinToString(",") { (addr, name) ->
|
||||
"""{"address":"$addr","name":${name?.let { jsonEscape(it) } ?: "null"}}"""
|
||||
}
|
||||
response = """{"count":${bonds.size},"bonds":[$items]}"""
|
||||
code = 200
|
||||
}
|
||||
method == "GET" && path.startsWith("/mercury/discover") -> {
|
||||
val seconds = queryParams["time"]?.toIntOrNull() ?: 5
|
||||
val found = MercuryBridge.discoverGlasses(seconds * 1_000L)
|
||||
val items = found.joinToString(",") { (name, addr) ->
|
||||
"""{"name":${name?.let { jsonEscape(it) } ?: "null"},"address":"$addr"}"""
|
||||
}
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"count":${found.size},"found":[$items],"connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/pair/stop") -> {
|
||||
MercuryBridge.cancelPairing()
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":true,"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/pair/classic") -> {
|
||||
val address = queryParams["address"]
|
||||
if (address.isNullOrBlank()) {
|
||||
response = """{"error":"нет параметра address"}"""
|
||||
code = 400
|
||||
} else {
|
||||
val ok = MercuryBridge.pairClassic(address)
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":$ok,"address":"$address","connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/pair") -> {
|
||||
val pairCode = MercuryBridge.startPairing()
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":${pairCode != null},"pairCode":${pairCode?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/unpair") -> {
|
||||
val address = queryParams["address"]
|
||||
if (address.isNullOrBlank()) {
|
||||
response = """{"error":"нет параметра address"}"""
|
||||
code = 400
|
||||
} else {
|
||||
val ok = MercuryBridge.unpair(address)
|
||||
response = """{"ok":$ok,"address":"$address"}"""
|
||||
code = 200
|
||||
}
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/connect") -> {
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
|
||||
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
@@ -151,6 +203,18 @@ object DebugHttpServer {
|
||||
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/wifi") -> {
|
||||
val enable = "true".equals(queryParams["enable"], ignoreCase = true)
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiToggle(enable) }
|
||||
response = """{"ok":$ok,"enable":$enable,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
method == "POST" && path.startsWith("/mercury/wifiscan") -> {
|
||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiScan() }
|
||||
val s = MercuryBridge.state.value
|
||||
response = """{"ok":$ok,"networks":${s.wifiNetworks.size},"status":${jsonEscape(s.statusText)}}"""
|
||||
code = 200
|
||||
}
|
||||
else -> {
|
||||
response = """{"error":"нет такого пути: $method $path"}"""
|
||||
code = 404
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.log.LogEntry
|
||||
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||
import pw.binom.viewmate.core.phone.GlassesSender
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
@@ -22,10 +23,12 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Gesture
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.LogBatchMsg
|
||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.core.protocol.SttText
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
@@ -95,7 +98,14 @@ class GlassesHub(
|
||||
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
|
||||
* рестарта телефона (см. PhoneApp.autostartAudio).
|
||||
*/
|
||||
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null
|
||||
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long, durationMs: Long) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
|
||||
* (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
|
||||
*/
|
||||
@Volatile
|
||||
var onLogBatch: ((List<LogEntry>) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
||||
@@ -186,13 +196,18 @@ class GlassesHub(
|
||||
this.state.playing = msg.playing
|
||||
val state = if (msg.playing) "играет" else "пауза"
|
||||
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}")
|
||||
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex)
|
||||
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs, msg.durationMs)
|
||||
}
|
||||
|
||||
is GlassesOff -> {
|
||||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||||
this.state.playing = false
|
||||
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
|
||||
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L, -1L)
|
||||
}
|
||||
|
||||
is LogBatchMsg -> {
|
||||
log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
|
||||
onLogBatch?.invoke(msg.entries)
|
||||
}
|
||||
|
||||
is SttAudio -> {
|
||||
@@ -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
|
||||
|
||||
import android.util.Log
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог телефона.
|
||||
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
|
||||
* (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
|
||||
* (android.util.Log недоступен — метод без @JvmStatic фолбэктнет в println),
|
||||
* чтобы не падать.
|
||||
*
|
||||
* Плюс лог-коллектор: пачки (свои + пришедшие от очков) уходят на сервер
|
||||
* (HttpLogSink); при сбое — прихраняются в споуле и ретраятся позже.
|
||||
*/
|
||||
@Volatile
|
||||
private var logCollector: BatchingLogCollector? = null
|
||||
|
||||
/** Подключить коллектор (PhoneApp.onCreate). */
|
||||
fun attachLogCollector(collector: BatchingLogCollector) {
|
||||
logCollector = collector
|
||||
}
|
||||
|
||||
fun log(tag: String, message: String) {
|
||||
val full = "[view-mate] $tag $message"
|
||||
runCatching {
|
||||
@@ -15,4 +27,5 @@ fun log(tag: String, message: String) {
|
||||
}.onFailure {
|
||||
println(full)
|
||||
}
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestBtPermissions()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
setContent {
|
||||
ViewMateApp()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.mercury.RayNeoMercury
|
||||
import pw.binom.mercury.model.MyWifiInfo
|
||||
import pw.binom.mercury.pair.PairManager
|
||||
|
||||
/**
|
||||
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
|
||||
@@ -38,6 +40,15 @@ object MercuryBridge {
|
||||
val glassesName: String? = null,
|
||||
val statusText: String = "не подключено",
|
||||
val lastError: String? = null,
|
||||
val brightness: Int = 0,
|
||||
val automaticBrightness: Boolean = false,
|
||||
val wifiEnabled: Boolean = false,
|
||||
val wifiSsid: String? = null,
|
||||
val wifiNetworks: List<MyWifiInfo> = emptyList(),
|
||||
/** Активный pairCode для QR-паринга (не null, пока очки не подключились). */
|
||||
val pairCode: String? = null,
|
||||
/** Реальный классический BT-адрес очков, полученный в pre-bond окне (null до паринга). */
|
||||
val realAddress: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
@@ -53,8 +64,15 @@ object MercuryBridge {
|
||||
mercury!!.events.collect { event ->
|
||||
when (event) {
|
||||
is RayNeoMercury.Event.Connected -> {
|
||||
_state.value = _state.value.copy(connected = true)
|
||||
_state.value = _state.value.copy(connected = true, pairCode = null)
|
||||
setStatus("подключено (BLE)")
|
||||
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
|
||||
// ползунок яркости и переключатель WiFi показывали реальные значения.
|
||||
scope.launch { mercury?.pullGeneralStatus() }
|
||||
// Синхронизируем часы очков с телефоном: до NTP очки стартуют с
|
||||
// нулевой меткой (~1970), и логи получают «старые» таймстемпы,
|
||||
// из-за чего Loki отвергает весь batch (HTTP 400) и спул клинит.
|
||||
scope.launch { mercury?.syncTime() }
|
||||
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
|
||||
// при гашении экрана очки рвут BLE-связь через ~3 секунды.
|
||||
// Повторяем, пока очки не будут готовы (они переподключаются
|
||||
@@ -94,8 +112,42 @@ object MercuryBridge {
|
||||
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
|
||||
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
|
||||
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}")
|
||||
is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}")
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> {
|
||||
val s = event.status
|
||||
_state.value = _state.value.copy(
|
||||
brightness = s.brightness,
|
||||
automaticBrightness = s.automaticBrightness,
|
||||
wifiEnabled = s.wifiEnabled,
|
||||
wifiSsid = s.wifiInfo?.ssid,
|
||||
)
|
||||
log("status", "brightness=${s.brightness} autoBrightness=${s.automaticBrightness} deepSuspend=${s.deepSuspend} wifi=${s.wifiEnabled} ssid=${s.wifiInfo?.ssid}")
|
||||
}
|
||||
is RayNeoMercury.Event.WifiScanResult -> {
|
||||
_state.value = _state.value.copy(wifiNetworks = event.networks)
|
||||
log("wifi", "сетей: ${event.networks.size}")
|
||||
}
|
||||
is RayNeoMercury.Event.RealBluetoothAddress -> {
|
||||
// Pre-bond handshake: очки сообщили свой реальный классический
|
||||
// BT-адрес (он же ожидает QR-pairCode). Создаём OS-level bond —
|
||||
// это и есть «паринг» (в стоке: PairStateMachine → AutoClassic → createBond).
|
||||
log("pair", "real bt address: ${event.address} name=${event.name} type=${event.type}")
|
||||
_state.value = _state.value.copy(realAddress = event.address)
|
||||
setStatus("получен адрес ${event.address}, настраиваю связь (bond)…")
|
||||
scope.launch {
|
||||
val m = mercury
|
||||
val res = if (m != null) m.pairByRealAddress(event.address)
|
||||
else PairManager.PairingResult.Failed("нет связи с библиотекой")
|
||||
when (res) {
|
||||
is PairManager.PairingResult.Success -> {
|
||||
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
|
||||
setStatus("спаривание успешно: ${event.address}, подключаюсь…")
|
||||
}
|
||||
is PairManager.PairingResult.Failed -> {
|
||||
setStatus("спаривание не удалось: ${res.reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +161,8 @@ object MercuryBridge {
|
||||
val m = mercury ?: return false
|
||||
return m.hasPermissions() &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +194,20 @@ object MercuryBridge {
|
||||
setStatus("подключено (клиент)")
|
||||
return true
|
||||
}
|
||||
// Без bond очки не отдают GATT-сервис — спариваем и пробуем ещё раз.
|
||||
setStatus("connect не удался, спариваю…")
|
||||
when (val res = m.pair(glasses)) {
|
||||
is PairManager.PairingResult.Success -> {
|
||||
if (m.connect(glasses, 8000)) {
|
||||
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
|
||||
setStatus("подключено (клиент, после pair)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
is PairManager.PairingResult.Failed -> {
|
||||
setStatus("спаривание не удалось: ${res.reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setError("очки не подключились за ${timeoutMillis / 1000} с")
|
||||
@@ -174,13 +241,158 @@ object MercuryBridge {
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Яркость экрана очков (0..100). Авто-яркость принудительно выключается — иначе очки игнорируют ручное значение. */
|
||||
suspend fun setBrightness(value: Int): Boolean {
|
||||
val v = value.coerceIn(0, 100)
|
||||
// При automaticBrightness=true очки отбрасывают brightness, поэтому выключаем авто заранее.
|
||||
mercury?.setAutoBrightness(false)
|
||||
val ok = mercury?.setBrightness(v) ?: false
|
||||
if (ok) _state.value = _state.value.copy(brightness = v, automaticBrightness = false)
|
||||
setStatus(if (ok) "яркость → $v%" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Авто-яркость очков. */
|
||||
suspend fun setAutoBrightness(enable: Boolean): Boolean {
|
||||
val ok = mercury?.setAutoBrightness(enable) ?: false
|
||||
if (ok) _state.value = _state.value.copy(automaticBrightness = enable)
|
||||
setStatus(if (ok) "авто-яркость → $enable" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Вкл/выкл WiFi-радио очков. */
|
||||
suspend fun wifiToggle(enable: Boolean): Boolean {
|
||||
val ok = mercury?.wifiToggle(enable) ?: false
|
||||
if (ok) _state.value = _state.value.copy(wifiEnabled = enable)
|
||||
setStatus(if (ok) "WiFi → ${if (enable) "вкл" else "выкл"}" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Подключить очки к WiFi-сети (пароль null, если открытая). */
|
||||
suspend fun wifiConnect(ssid: String, password: String?): Boolean {
|
||||
val ok = mercury?.wifiConnect(ssid, password) ?: false
|
||||
setStatus(if (ok) "WiFi: подключение к «$ssid»…" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Забыть сохранённую WiFi-сеть на очках. */
|
||||
suspend fun wifiRemove(ssid: String): Boolean {
|
||||
val ok = mercury?.wifiRemove(ssid) ?: false
|
||||
setStatus(if (ok) "WiFi: забыть «$ssid»" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Сканировать доступные WiFi-сети (результат → state.wifiNetworks). */
|
||||
suspend fun wifiScan(): Boolean {
|
||||
val ok = mercury?.wifiScan() ?: false
|
||||
setStatus(if (ok) "WiFi: сканирование…" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Запросить актуальный статус очков (яркость/WiFi/батарея). */
|
||||
suspend fun pullStatus(): Boolean {
|
||||
val ok = mercury?.pullGeneralStatus() ?: false
|
||||
if (!ok) setStatus("ошибка запроса статуса (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* Старт паринга: перезапускает GATT-сервер с pairCode в рекламе и отдаёт код
|
||||
* для QR. Пользователь показывает QR экрану очков — они его сканируют,
|
||||
* подключаются в pre-bond окне и шлют свой реальный адрес
|
||||
* (Event.RealBluetoothAddress), по которому мы делаем createBond.
|
||||
*/
|
||||
fun startPairing(): String? {
|
||||
val m = mercury ?: return null
|
||||
if (m.isAnyConnected) {
|
||||
setStatus("очки уже подключены — паринг не нужен")
|
||||
return null
|
||||
}
|
||||
val code = m.startPairing()
|
||||
_state.value = _state.value.copy(pairCode = code)
|
||||
setStatus("паринг: покажите QR экрану очков (код $code)")
|
||||
return code
|
||||
}
|
||||
|
||||
/** Отмена активного QR-паринга (прячет QR, перезапускает сервер в обычном режиме без pairCode). */
|
||||
fun cancelPairing() {
|
||||
_state.value = _state.value.copy(pairCode = null)
|
||||
mercury?.stopPairing()
|
||||
setStatus("паринг отменён")
|
||||
}
|
||||
|
||||
/**
|
||||
* Забывает очки (removeBond). Применяется, когда очки на своей стороне
|
||||
* потеряли бонд, а телефон держит «старый» бонд — рассинхрон блокирует
|
||||
* нормальное повторное паринг.
|
||||
*/
|
||||
fun unpair(address: String): Boolean {
|
||||
val m = mercury ?: return false
|
||||
return kotlinx.coroutines.runBlocking { m.unpairAddress(address) }
|
||||
}
|
||||
|
||||
/** Текущие классические BT-бонды телефона (адрес → имя). Debug-помощник. */
|
||||
fun bondedAddresses(): List<Pair<String, String?>> =
|
||||
mercury?.bondedAddresses() ?: emptyList()
|
||||
|
||||
/**
|
||||
* Классический паринг по известному классическому адресу — ручной путь без
|
||||
* QR и камеры очков (то самое «подключите вручную в приложении» с экрана
|
||||
* очков). createBond по адресу; очки после BONDED сами уходят из паринг-режима.
|
||||
*/
|
||||
fun pairClassic(address: String): Boolean {
|
||||
val m = mercury ?: return false
|
||||
val res = kotlinx.coroutines.runBlocking { m.pairByRealAddress(address) }
|
||||
return if (res is PairManager.PairingResult.Success) {
|
||||
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
|
||||
setStatus("классический паринг успешен: $address (${res.device.name})")
|
||||
true
|
||||
} else {
|
||||
setError("классический паринг не удался: ${(res as? PairManager.PairingResult.Failed)?.reason}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Классический discovery: находит RayNeo-очки в режиме первого паринга вокруг
|
||||
* телефона (без камеры). Возвращает список найденных (имя, адрес).
|
||||
*/
|
||||
fun discoverGlasses(timeoutMs: Long = 5_000): List<Pair<String, String?>> {
|
||||
val m = mercury ?: return emptyList()
|
||||
val found = kotlinx.coroutines.runBlocking { m.discoverClassic(timeoutMs) }
|
||||
return found.map { it.device.name to it.address }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручной паринг «в один клик»: классический discovery, выбор RayNeo-очков и
|
||||
* createBond. Кнопка вместо QR — для случая, когда очки снова в режиме первого
|
||||
* паринга (слетел бонд) и камера очков недоступна/не считывает QR.
|
||||
*/
|
||||
fun pairClassicAuto(timeoutMs: Long = 7_000): String? {
|
||||
val found = discoverGlasses(timeoutMs)
|
||||
if (found.isEmpty()) {
|
||||
setError("ручной паринг: рядом не найдено ни одного классического устройства — включите очки")
|
||||
return null
|
||||
}
|
||||
val target = found.firstOrNull { (it.first)?.contains("rayneo", ignoreCase = true) == true }
|
||||
if (target == null) {
|
||||
setError("ручной паринг: RayNeo рядом не найден (проверьте, что очки включены и в режиме паринга)")
|
||||
return null
|
||||
}
|
||||
val address = target.second
|
||||
val ok = pairClassic(address ?: return null)
|
||||
if (!ok) return null
|
||||
setStatus("ручной паринг: очки подключены ($address)")
|
||||
return address
|
||||
}
|
||||
|
||||
private fun setStatus(text: String) {
|
||||
log("status", text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = null)
|
||||
}
|
||||
|
||||
private fun setError(text: String) {
|
||||
Log.e(TAG, text)
|
||||
log("mercury", text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = text)
|
||||
}
|
||||
|
||||
@@ -193,6 +405,6 @@ object MercuryBridge {
|
||||
}
|
||||
|
||||
private fun log(tag: String, msg: String) {
|
||||
Log.d(TAG, "[$tag] $msg")
|
||||
pw.binom.viewmate.phone.log(tag, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import ai.koog.prompt.executor.clients.openai.base.models.OpenAIMessage
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.response.respondTextWriter
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import pw.binom.viewmate.phone.agent.ChatMessage
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* OpenAI-совместимый HTTP-сервер (Ktor-CIO, в стиле [WifiServerTransport]):
|
||||
* - POST /v1/chat/completions — stream=false → JSON chat.completion,
|
||||
* stream=true → SSE-чанки chat.completion.chunk + [DONE];
|
||||
* - GET /v1/models, GET / — самодокументация.
|
||||
*
|
||||
* Мессиджи запроса парсятся DTO koog [OpenAIMessage] (парсер OpenAI-клиента:
|
||||
* роль → типизированный сабтип, content — строка или массив parts,
|
||||
* tool + tool_call_id). Ответы — ручной snake_case JSON: DTO koog для
|
||||
* ответов (OpenAIChatCompletionResponse) сериализуют finishReason в
|
||||
* camelCase (клиентская конвенция), что не OpenAI wire-формат.
|
||||
*
|
||||
* Бэкенд — [PhoneApp.openAiLlm] (текущий выбор модели: локальный LiteRT-LM
|
||||
* или удалённый по ключу); null → 503. Ядро [handleChat] вынесено из
|
||||
* сокета/корутины Ktor — юнит-тестится на JVM.
|
||||
*/
|
||||
object OpenAiHttpServer {
|
||||
|
||||
private const val MODEL_NAME = "viewmate"
|
||||
|
||||
/** Задержка между SSE-фреймами эмулированного стрима (движок не стримит нативно). */
|
||||
private const val CHUNK_DELAY_MS = 25L
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Volatile
|
||||
private var started = false
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
/** Поднимает Ktor-сервер на [PhoneConfig.OPENAI_SERVER_PORT] и печатает все IP устройства. */
|
||||
fun start(app: PhoneApp) {
|
||||
if (started) return
|
||||
started = true
|
||||
try {
|
||||
server = embeddedServer(CIO, port = PhoneConfig.OPENAI_SERVER_PORT, host = "0.0.0.0") {
|
||||
openAiModule(app)
|
||||
}
|
||||
server!!.start(wait = false)
|
||||
log("openai", "OpenAI-HTTP (Ktor-CIO) на 0.0.0.0:${PhoneConfig.OPENAI_SERVER_PORT}")
|
||||
log("openai", "Эндпоинты: /v1/chat/completions (stream=true|false), /v1/models, /")
|
||||
allIps().forEach { ip ->
|
||||
log("openai", "IP: $ip → http://$ip:${PhoneConfig.OPENAI_SERVER_PORT}/v1/chat/completions")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("openai", "не удалось запустить: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Ktor-модуль ----------
|
||||
|
||||
private fun Application.openAiModule(app: PhoneApp) {
|
||||
routing {
|
||||
post("/v1/chat/completions") {
|
||||
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
|
||||
when (val outcome = handleChat(app.openAiLlm(), call.receiveText())) {
|
||||
is ChatOutcome.Json -> call.respondText(
|
||||
text = outcome.body,
|
||||
contentType = ContentType.Application.Json,
|
||||
status = statusCode(outcome.code),
|
||||
)
|
||||
|
||||
is ChatOutcome.Sse -> sendSse(call, outcome)
|
||||
}
|
||||
}
|
||||
get("/v1/models") {
|
||||
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
|
||||
call.respondText(modelsJson(), ContentType.Application.Json)
|
||||
}
|
||||
get("/") {
|
||||
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
|
||||
call.respondText(helpJson(), ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE-стрим: фреймы [ChatOutcome.Sse.frames] с задержкой [CHUNK_DELAY_MS]. */
|
||||
private suspend fun sendSse(call: ApplicationCall, sse: ChatOutcome.Sse) {
|
||||
call.respondTextWriter(
|
||||
contentType = ContentType.parse("text/event-stream; charset=utf-8"),
|
||||
status = HttpStatusCode.OK,
|
||||
) {
|
||||
sse.frames.forEach { frame ->
|
||||
append(frame)
|
||||
flush()
|
||||
delay(CHUNK_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusCode(code: Int): HttpStatusCode = when (code) {
|
||||
200 -> HttpStatusCode.OK
|
||||
400 -> HttpStatusCode.BadRequest
|
||||
502 -> HttpStatusCode.BadGateway
|
||||
503 -> HttpStatusCode.ServiceUnavailable
|
||||
else -> HttpStatusCode(code, "Error")
|
||||
}
|
||||
|
||||
// ---------- ядро (без сокета: юнит-тестится) ----------
|
||||
|
||||
/** Итог обработки /v1/chat/completions: JSON-тело с кодом или список SSE-фреймов. */
|
||||
internal sealed interface ChatOutcome {
|
||||
data class Json(val code: Int, val body: String) : ChatOutcome
|
||||
data class Sse(val frames: List<String>) : ChatOutcome
|
||||
}
|
||||
|
||||
internal suspend fun handleChat(llm: LlmClient?, body: String): ChatOutcome {
|
||||
val req = try {
|
||||
json.decodeFromJsonElement(OaiRequest.serializer(), normalizeOpenAiElement(body))
|
||||
} catch (e: Exception) {
|
||||
return ChatOutcome.Json(400, errorJson("некорректный JSON body", 400))
|
||||
}
|
||||
val (system, history) = runCatching { splitMessages(req) }.getOrElse { e ->
|
||||
return ChatOutcome.Json(400, errorJson(e.message ?: "некорректные messages", 400))
|
||||
}
|
||||
if (history.isEmpty()) return ChatOutcome.Json(400, errorJson("messages не может быть пустым", 400))
|
||||
if (llm == null) {
|
||||
return ChatOutcome.Json(503, errorJson("LLM недоступен: API-ключ не задан или модель не загружена", 503))
|
||||
}
|
||||
val text = try {
|
||||
llm.chat(history, system)
|
||||
} catch (e: Exception) {
|
||||
log("openai", "ошибка LLM: ${e.message}")
|
||||
return ChatOutcome.Json(502, errorJson("ошибка LLM: ${e.message}", 502))
|
||||
}
|
||||
log("openai", "запрос: stream=${req.stream}, msg=${req.messages.size} → ${text.length} симв")
|
||||
|
||||
val id = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "").take(12)
|
||||
val created = System.currentTimeMillis() / 1000
|
||||
return if (req.stream) ChatOutcome.Sse(streamFrames(id, created, text))
|
||||
else ChatOutcome.Json(200, completionJson(id, created, text))
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI wire → DTO koog: tool-сообщения несут `tool_call_id` (snake_case),
|
||||
* а парсер koog ждёт `toolCallId` (camelCase, клиентская конвенция).
|
||||
* Для tool-роли дописываем camelCase-копию (или пустую, если поля нет).
|
||||
* Остальное — без изменений.
|
||||
*/
|
||||
private fun normalizeOpenAiElement(raw: String): JsonElement {
|
||||
val root = json.decodeFromString(JsonElement.serializer(), raw)
|
||||
val obj = root as? JsonObject ?: return root
|
||||
val rawMsgs = obj["messages"] as? JsonArray ?: return root
|
||||
val fixed = rawMsgs.map { el ->
|
||||
val m = el as? JsonObject ?: return@map el
|
||||
if (m["role"]?.jsonPrimitive?.contentOrNull == "tool" && !m.containsKey("toolCallId")) {
|
||||
val tcid = m["tool_call_id"]?.jsonPrimitive?.contentOrNull ?: ""
|
||||
JsonObject(m.toMutableMap().apply { put("toolCallId", JsonPrimitive(tcid)) })
|
||||
} else el
|
||||
}
|
||||
return JsonObject(obj.toMutableMap().apply { put("messages", JsonArray(fixed)) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Мессиджи OpenAI → (system-промпт, история): system/developer склеиваются
|
||||
* в system; user/assistant/tool (tool_call_id) → [ChatMessage].
|
||||
* Content у DTO koog: строка или массив parts — [ai.koog.prompt.executor.clients.openai.base.models.Content.text].
|
||||
*/
|
||||
private fun splitMessages(req: OaiRequest): Pair<String?, List<ChatMessage>> {
|
||||
val systems = mutableListOf<String>()
|
||||
val history = mutableListOf<ChatMessage>()
|
||||
for (m in req.messages) {
|
||||
val content = m.content?.text().orEmpty()
|
||||
when (m) {
|
||||
is OpenAIMessage.System, is OpenAIMessage.Developer -> systems.add(content)
|
||||
is OpenAIMessage.User -> history.add(ChatMessage.of(ChatRole.USER, content))
|
||||
is OpenAIMessage.Assistant -> history.add(ChatMessage.of(ChatRole.ASSISTANT, content))
|
||||
is OpenAIMessage.Tool ->
|
||||
history.add(ChatMessage.of(ChatRole.TOOL, content, toolCallId = m.toolCallId))
|
||||
}
|
||||
}
|
||||
val system = systems.joinToString("\n").ifEmpty { null }
|
||||
return system to history
|
||||
}
|
||||
|
||||
// ---------- константы (без сокета) ----------
|
||||
|
||||
/** Все IPv4/IPv6 адреса интерфейсов + 127.0.0.1 — для логов и тестов. */
|
||||
fun allIps(): List<String> {
|
||||
val ips = linkedSetOf("127.0.0.1")
|
||||
try {
|
||||
for (intf in java.net.NetworkInterface.getNetworkInterfaces()) {
|
||||
if (!intf.isUp || intf.isLoopback) continue
|
||||
for (addr in intf.inetAddresses) {
|
||||
if (addr is java.net.Inet4Address) addr.hostAddress?.let(ips::add)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return ips.toList()
|
||||
}
|
||||
|
||||
private fun completionJson(id: String, created: Long, text: String): String =
|
||||
"{" +
|
||||
"\"id\":\"$id\"," +
|
||||
"\"object\":\"chat.completion\"," +
|
||||
"\"created\":$created," +
|
||||
"\"model\":\"$MODEL_NAME\"," +
|
||||
"\"choices\":[{" +
|
||||
"\"index\":0," +
|
||||
"\"message\":{\"role\":\"assistant\",\"content\":\"${jsonEscape(text)}\"}," +
|
||||
"\"finish_reason\":\"stop\"" +
|
||||
"}]," +
|
||||
"\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0}" +
|
||||
"}"
|
||||
|
||||
/** Фреймы SSE-стрима: роль, по слову (слово + следующие пробелы), finish + [DONE]. */
|
||||
private fun streamFrames(id: String, created: Long, text: String): List<String> {
|
||||
val frames = mutableListOf<String>()
|
||||
frames.add(sseChunk(id, created, role = true))
|
||||
for (m in Regex("""\S+\s*""").findAll(text)) {
|
||||
frames.add(sseChunk(id, created, delta = m.value))
|
||||
}
|
||||
frames.add(sseChunk(id, created, finish = true))
|
||||
frames.add("data: [DONE]\n\n")
|
||||
return frames
|
||||
}
|
||||
|
||||
private fun sseChunk(id: String, created: Long, role: Boolean = false, delta: String? = null, finish: Boolean = false): String {
|
||||
val deltaJson = buildString {
|
||||
if (role) append("\"role\":\"assistant\"")
|
||||
if (delta != null) {
|
||||
if (role) append(',')
|
||||
append("\"content\":\"${jsonEscape(delta)}\"")
|
||||
}
|
||||
}
|
||||
val finishReason = if (finish) "\"stop\"" else "null"
|
||||
val jsonBody = "{" +
|
||||
"\"id\":\"$id\"," +
|
||||
"\"object\":\"chat.completion.chunk\"," +
|
||||
"\"created\":$created," +
|
||||
"\"model\":\"$MODEL_NAME\"," +
|
||||
"\"choices\":[{" +
|
||||
"\"index\":0," +
|
||||
"\"delta\":{$deltaJson}," +
|
||||
"\"finish_reason\":$finishReason" +
|
||||
"}]" +
|
||||
"}"
|
||||
return "data: $jsonBody\n\n"
|
||||
}
|
||||
|
||||
private fun errorJson(message: String, code: Int): String = buildString {
|
||||
val type = if (code in 400..499) "invalid_request_error" else "server_error"
|
||||
append("{\"error\":{\"message\":\"").append(jsonEscape(message)).append("\",")
|
||||
append("\"type\":\"").append(type).append("\",\"code\":null}}")
|
||||
}
|
||||
|
||||
private fun modelsJson(): String =
|
||||
"{\"object\":\"list\",\"data\":[{" +
|
||||
"\"id\":\"$MODEL_NAME\"," +
|
||||
"\"object\":\"model\"," +
|
||||
"\"created\":0," +
|
||||
"\"owned_by\":\"viewmate\"" +
|
||||
"}]"
|
||||
|
||||
private fun helpJson(): String =
|
||||
"{\"message\":\"Viewmate OpenAI-совместимый сервер\"," +
|
||||
"\"endpoints\":[{" +
|
||||
"\"method\":\"POST\",\"path\":\"/v1/chat/completions\"," +
|
||||
"\"description\":\"Chat completion (stream=true|false)\"," +
|
||||
"\"model\":\"$MODEL_NAME\"}]," +
|
||||
"\"models\":[\"$MODEL_NAME\"],\"port\":${PhoneConfig.OPENAI_SERVER_PORT}}"
|
||||
|
||||
private fun jsonEscape(s: String): String =
|
||||
s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
|
||||
// ---------- DTO запроса ----------
|
||||
|
||||
@Serializable
|
||||
private data class OaiRequest(
|
||||
val model: String? = null,
|
||||
val messages: List<OpenAIMessage> = emptyList(),
|
||||
val stream: Boolean = false,
|
||||
)
|
||||
}
|
||||
@@ -17,12 +17,18 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
import pw.binom.viewmate.core.log.FileLogSpool
|
||||
import pw.binom.viewmate.core.log.LokiConfig
|
||||
import pw.binom.viewmate.core.log.SystemClock
|
||||
import pw.binom.viewmate.core.log.lokiLogSink
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.phone.PhoneActions
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.AssistantState
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.AudioSyncInfo
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
@@ -38,6 +44,7 @@ import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
||||
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LlmPrefs
|
||||
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
|
||||
@@ -49,7 +56,6 @@ import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||
import pw.binom.viewmate.phone.stt.PhraseRecognizer
|
||||
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
|
||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||
|
||||
/**
|
||||
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
|
||||
@@ -84,6 +90,13 @@ class PhoneApp : Application() {
|
||||
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
||||
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
||||
|
||||
/** Прогресс просмотра по элементам (filesDir/watchprogress.json): возобновление + индикация в каталоге. */
|
||||
val watchProgress: WatchProgressStore by lazy { WatchProgressStore(filesDir) }
|
||||
|
||||
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
|
||||
lateinit var logCollector: BatchingLogCollector
|
||||
private set
|
||||
|
||||
/** История общения с ассистентом (SQLite, "assistant.db"). */
|
||||
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
|
||||
|
||||
@@ -119,6 +132,28 @@ class PhoneApp : Application() {
|
||||
/** Текущий локальный LLM-клиент (для DebugHttpServer / статуса). */
|
||||
fun localLlmClient(): LocalLlmClient? = localLlm
|
||||
|
||||
/**
|
||||
* LLM-клиент по текущему выбору [llmChoice] для OpenAI-HTTP-сервера
|
||||
* ([OpenAiHttpServer]) — без тул-цикла ассистента (клиент OpenAI API сам
|
||||
* управляет контекстом). Локальный клиент переиспользует [localLlm]
|
||||
* (движок LiteRT один на процесс). null — серверный без ключа или локальная
|
||||
* модель ещё не скачана.
|
||||
*/
|
||||
fun openAiLlm(): LlmClient? = synchronized(assistantLock) {
|
||||
when (_llmChoice.value) {
|
||||
LlmModelChoice.REMOTE ->
|
||||
if (BuildConfig.LLM_API_KEY.isBlank()) null
|
||||
else RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||
|
||||
LlmModelChoice.LOCAL ->
|
||||
if (!localModelFile().isFile) null
|
||||
else localLlm ?: LocalLlmClient(
|
||||
modelFile = localModelFile(),
|
||||
cacheDir = File(cacheDir, "litertlm"),
|
||||
).also { localLlm = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
|
||||
// lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
|
||||
// getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
|
||||
@@ -215,8 +250,11 @@ class PhoneApp : Application() {
|
||||
).also { toolkit = it }
|
||||
return Assistant(
|
||||
chatDao = chatDao,
|
||||
chat = { history, system -> llm.chat(history, system) },
|
||||
chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
|
||||
toolkit = tk,
|
||||
// Нативный тул-канал LiteRT: только локальная модель видит схемы
|
||||
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
|
||||
nativeTools = choice == LlmModelChoice.LOCAL,
|
||||
// Локальной модели нужно время: инициализация движка ~17с на
|
||||
// CPU + генерация. Серверная укладывается в 30с, локальной
|
||||
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
|
||||
@@ -345,9 +383,9 @@ class PhoneApp : Application() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
||||
* Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models —
|
||||
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
||||
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
|
||||
* на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
|
||||
*/
|
||||
private fun ensureStt(): SttStreamer? {
|
||||
synchronized(sttLock) {
|
||||
@@ -357,39 +395,24 @@ class PhoneApp : Application() {
|
||||
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
|
||||
val qwenDec = File(modelsDir, "decoder.int8.onnx")
|
||||
val qwenTok = File(modelsDir, "tokenizer")
|
||||
var stt: PhraseRecognizer
|
||||
if (STT_BACKEND == "qwen3_asr" &&
|
||||
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
|
||||
) {
|
||||
stt = Qwen3AsrStt(
|
||||
convFrontendPath = qwenConv.absolutePath,
|
||||
encoderPath = qwenEnc.absolutePath,
|
||||
decoderPath = qwenDec.absolutePath,
|
||||
tokenizerDir = qwenTok.absolutePath,
|
||||
numThreads = 4,
|
||||
hotwords = STT_HOTWORDS,
|
||||
)
|
||||
} else {
|
||||
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||
val 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,
|
||||
)
|
||||
if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
|
||||
log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||
return null
|
||||
}
|
||||
val stt: PhraseRecognizer = Qwen3AsrStt(
|
||||
convFrontendPath = qwenConv.absolutePath,
|
||||
encoderPath = qwenEnc.absolutePath,
|
||||
decoderPath = qwenDec.absolutePath,
|
||||
tokenizerDir = qwenTok.absolutePath,
|
||||
numThreads = 4,
|
||||
hotwords = STT_HOTWORDS,
|
||||
)
|
||||
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
||||
File(filesDir, "stt_dump_phone.raw")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
|
||||
log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)")
|
||||
val s = SttStreamer(
|
||||
stt = stt,
|
||||
onSilence30s = {
|
||||
@@ -406,7 +429,7 @@ class PhoneApp : Application() {
|
||||
)
|
||||
sttStreamer = s
|
||||
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||
log("stt", "SttStreamer готов ($STT_BACKEND)")
|
||||
log("stt", "SttStreamer готов (qwen3_asr)")
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -449,9 +472,31 @@ class PhoneApp : Application() {
|
||||
server.start()
|
||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||
DebugHttpServer.start(this)
|
||||
OpenAiHttpServer.start(this)
|
||||
nsd.publish()
|
||||
server.hub.sttFactory = { ensureStt() }
|
||||
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
|
||||
|
||||
// Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki
|
||||
// выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool.
|
||||
logCollector = BatchingLogCollector(
|
||||
device = "phone",
|
||||
sink = lokiLogSink(),
|
||||
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64),
|
||||
clock = SystemClock(),
|
||||
flushIntervalMs = 60_000,
|
||||
maxBatchEntries = 500,
|
||||
)
|
||||
logCollector.start(scope)
|
||||
attachLogCollector(logCollector)
|
||||
pw.binom.viewmate.core.logCollector = logCollector
|
||||
server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) }
|
||||
log(
|
||||
"server",
|
||||
"лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " +
|
||||
"спул: filesDir/logspool)",
|
||||
)
|
||||
|
||||
scope.launch { assistantLoop() }
|
||||
|
||||
val lastPositionAt = AtomicLong(0)
|
||||
@@ -459,11 +504,25 @@ class PhoneApp : Application() {
|
||||
|
||||
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
||||
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
|
||||
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex ->
|
||||
// + прогресс просмотра (resume + индикация в каталоге)
|
||||
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs, durationMs ->
|
||||
lastPositionAt.set(System.currentTimeMillis())
|
||||
lastWasPlaying.set(playing)
|
||||
audioSync.sync(positionMs, playing)
|
||||
audioSync.sync(positionMs, playing, sentTs)
|
||||
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
|
||||
if (itemId != null) {
|
||||
scope.launch(Dispatchers.IO) { watchProgress.upsert(itemId, positionMs, durationMs) }
|
||||
}
|
||||
}
|
||||
|
||||
// Индикатор аудио-синхронизации в статус-панели очков: |звук − видео| в мс
|
||||
// на каждый sync-цикл (~5с); active=false (видео на очках не играет) — панель скрывает.
|
||||
scope.launch {
|
||||
audioSync.diffMs.collect { diff ->
|
||||
server.hub.broadcast(
|
||||
AudioSyncInfo(diffMs = diff.toInt(), active = audioSync.isActive && audioSync.videoPlaying),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту
|
||||
@@ -534,9 +593,6 @@ class PhoneApp : Application() {
|
||||
lateinit var instance: PhoneApp
|
||||
private set
|
||||
|
||||
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
|
||||
private const val STT_BACKEND = "qwen3_asr"
|
||||
|
||||
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ object PhoneConfig {
|
||||
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
|
||||
const val SERVER_PORT = 8080
|
||||
|
||||
/** Порт OpenAI-совместимого HTTP-сервера ([OpenAiHttpServer], POST /v1/chat/completions). */
|
||||
const val OPENAI_SERVER_PORT = 8800
|
||||
|
||||
/**
|
||||
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
|
||||
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
|
||||
@@ -19,8 +22,13 @@ object PhoneConfig {
|
||||
*/
|
||||
const val USE_BLUETOOTH = true
|
||||
|
||||
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
|
||||
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
|
||||
/**
|
||||
* Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит).
|
||||
* 10 с = два пропущенных пинга: 5-секундные пинги очков джиттят (интервалы 3.8–6.3 с —
|
||||
* SPP-запись / main-поток очков задерживаются), и порог 5 с ложно ставил звук на паузу
|
||||
* при каждом позднем пинге (слышимый провал 200–800 мс, «звук возобновился с того же места»).
|
||||
*/
|
||||
const val AUDIO_SILENCE_PAUSE_MS = 10_000L
|
||||
|
||||
/** Период проверки watchdog. */
|
||||
const val WATCHDOG_TICK_MS = 2_000L
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Прогресс просмотра элемента: как далеко досмотрели и длительность видео. */
|
||||
@Serializable
|
||||
data class WatchProgress(
|
||||
val positionMs: Long,
|
||||
/** Длительность видео, мс; -1 = неизвестно (старые очки не сообщали durationMs). */
|
||||
val durationMs: Long = -1L,
|
||||
) {
|
||||
/** Процент просмотренного (0..100); null — длительность неизвестна. */
|
||||
fun watchedPercent(): Int? =
|
||||
if (durationMs <= 0L) null else ((positionMs.toDouble() / durationMs) * 100.0).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class WatchProgressFile(val entries: Map<String, WatchProgress>)
|
||||
|
||||
/**
|
||||
* Прогресс просмотра по элементам: `filesDir/watchprogress.json`.
|
||||
*
|
||||
* Заполняется из PlaybackPosition очков (раз в 5с, только при продвижении вперёд),
|
||||
* используется в двух местах: (1) возобновление просмотра с того же места по «▶ Смотреть»
|
||||
* и (2) индикация просмотренного на карточках каталога.
|
||||
* Ключ — itemId (не зависит от дорожки звука — позиция общая).
|
||||
* Конструктор принимает директорию (в проде — filesDir) для JVM-тестируемости.
|
||||
*/
|
||||
class WatchProgressStore(dir: File) {
|
||||
|
||||
companion object {
|
||||
/** Понять «новое начало»: просмотрено меньше 15 с — возобновляем с начала. */
|
||||
const val MIN_RESUME_MS = 15_000L
|
||||
|
||||
/** 98% и больше — элемент досмотрен: в следующий раз начинаем сначала. */
|
||||
const val FINISHED_RATIO = 0.98
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
|
||||
private val file = File(dir, "watchprogress.json")
|
||||
private val lock = Any()
|
||||
private val entries = LinkedHashMap<String, WatchProgress>()
|
||||
|
||||
private val _flow = MutableStateFlow<Map<String, WatchProgress>>(emptyMap())
|
||||
/** Снимки прогресса по itemId — для карточек каталога. */
|
||||
val flow: StateFlow<Map<String, WatchProgress>> = _flow.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
_flow.value = entries.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать позицию элемента. На диск пишем только при продвижении вперёд
|
||||
* или когда впервые известна длительность; дубликаты (пауза) — без записи.
|
||||
* Позиция дальше длительности — мусор ExoPlayer (гашение экрана): игнор.
|
||||
*/
|
||||
fun upsert(itemId: String, positionMs: Long, durationMs: Long = -1L) {
|
||||
if (positionMs < 0L) return
|
||||
synchronized(lock) {
|
||||
val old = entries[itemId] ?: WatchProgress(0L)
|
||||
val knownDuration = if (durationMs > 0L) durationMs else old.durationMs
|
||||
if (knownDuration > 0L && positionMs > knownDuration) {
|
||||
log("progress", "упсерт отклонён: позиция $positionMs мс > длительность $knownDuration мс ($itemId)")
|
||||
return
|
||||
}
|
||||
val advances = positionMs > old.positionMs ||
|
||||
(durationMs > 0L && durationMs != old.durationMs)
|
||||
if (!advances) return
|
||||
entries[itemId] = WatchProgress(positionMs, if (durationMs > 0L) durationMs else old.durationMs)
|
||||
persist()
|
||||
_flow.value = entries.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиция для возобновления просмотра: null = начинать с начала
|
||||
* (не смотрели, смотрели меньше [MIN_RESUME_MS] или досмотрели до [FINISHED_RATIO]).
|
||||
*/
|
||||
fun resumePosition(itemId: String): Long? {
|
||||
val e = synchronized(lock) { entries[itemId] } ?: return null
|
||||
return when {
|
||||
e.positionMs < MIN_RESUME_MS -> null
|
||||
e.durationMs > 0L && e.positionMs >= e.durationMs * FINISHED_RATIO -> null
|
||||
else -> e.positionMs
|
||||
}
|
||||
}
|
||||
|
||||
/** Процент просмотренного (0..100); null — элемент не смотрели или длительность неизвестна. */
|
||||
fun watchedPercent(itemId: String): Int? {
|
||||
val e = synchronized(lock) { entries[itemId] } ?: return null
|
||||
return e.watchedPercent()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val text = runCatching { file.readText() }.getOrNull() ?: return
|
||||
val parsed = runCatching { json.decodeFromString(WatchProgressFile.serializer(), text).entries }.getOrNull()
|
||||
if (parsed != null) {
|
||||
entries.putAll(parsed)
|
||||
} else {
|
||||
log("progress", "watchprogress.json не прочитан — начинаю пустым")
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
val result = runCatching { json.encodeToString(WatchProgressFile.serializer(), WatchProgressFile(entries.toMap())) }
|
||||
result.onSuccess { text ->
|
||||
runCatching { file.writeText(text) }.onFailure { log("progress", "запись watchprogress.json: ${it.message}") }
|
||||
}.onFailure { log("progress", "сериализация watchprogress.json: ${it.message}") }
|
||||
}
|
||||
}
|
||||
@@ -54,15 +54,24 @@ class AgentToolkit(
|
||||
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
|
||||
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
|
||||
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
|
||||
*
|
||||
* [nativeTools] — нативный тул-канал (локальная модель, LiteRT): тулы
|
||||
* модель видит через JSON-схемы в движке, строку текстового протокола
|
||||
* TOOL_CALL в промпт НЕ кладем (иначе 2B-модель путает каналы).
|
||||
*/
|
||||
fun systemPrompt(sessionId: Long): String {
|
||||
fun systemPrompt(sessionId: Long, nativeTools: Boolean = false): String {
|
||||
val note = pendingNotes.remove(sessionId)
|
||||
val activeSets = toolsets.activeNames(sessionId)
|
||||
val sb = StringBuilder()
|
||||
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
|
||||
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
|
||||
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
|
||||
.append("После получения результата тула ответь пользователю текстом.")
|
||||
if (nativeTools) {
|
||||
sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
|
||||
.append("После получения результата тула ответь пользователю текстом.")
|
||||
} else {
|
||||
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
|
||||
.append("После получения результата тула ответь пользователю текстом.")
|
||||
}
|
||||
sb.append("\n\nЯдро:")
|
||||
CORE_LINES.forEach { sb.append('\n').append(it) }
|
||||
val catalog = toolsets.catalogLines()
|
||||
@@ -85,6 +94,27 @@ class AgentToolkit(
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Нейтральные схемы тул для [LlmClient.chatWithTools] (нативный канал):
|
||||
* ядро (5 тул) + тулы АКТИВНЫХ тулсетов (с полными именами `набор_тул`).
|
||||
* Неактивные тулсеты сюда не попадают — модель увидит их только после
|
||||
* enable_toolset (в следующем callLlm схема пересобирается).
|
||||
*/
|
||||
fun toolSpecs(sessionId: Long): List<ToolSpec> {
|
||||
val out = mutableListOf(
|
||||
ToolSpec(TOOL_TIME, "Текущие дата, время и часовой пояс (ISO 8601)"),
|
||||
ToolSpec(TOOL_SHOW_TEXT, "Показать текст на очках", listOf("text")),
|
||||
ToolSpec(TOOL_READ_SKILL, "Прочитать полное содержимое скилла по имени", listOf("name")),
|
||||
ToolSpec(TOOL_ENABLE, "Активировать тулсет (живёт 10 мин от последнего вызова его тул)", listOf("name")),
|
||||
ToolSpec(TOOL_DISABLE, "Деактивировать тулсет", listOf("name")),
|
||||
)
|
||||
for (setName in toolsets.activeNames(sessionId)) {
|
||||
val ts = toolsets.toolset(setName) ?: continue
|
||||
ts.tools.forEach { s -> out += ToolSpec(ts.toolFull(s), s.description, s.args) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- парсинг аргументов ----------
|
||||
|
||||
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
|
||||
@@ -98,31 +128,11 @@ class AgentToolkit(
|
||||
|
||||
/**
|
||||
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
||||
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
||||
* одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
|
||||
* (общая с RemoteLlmClient), метод оставлен для API/тестов.
|
||||
*/
|
||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
|
||||
val s = raw?.trim()
|
||||
if (s.isNullOrEmpty()) return emptyMap()
|
||||
val required = expected.filterNot { it.endsWith("?") }.distinct()
|
||||
val out = LinkedHashMap<String, String>()
|
||||
var bareIndex = 0
|
||||
for (field in s.split(ALTER)) {
|
||||
val f = field.trim()
|
||||
if (f.isEmpty()) continue
|
||||
val eq = f.indexOf('=')
|
||||
if (eq > 0) {
|
||||
val key = f.substring(0, eq).trim()
|
||||
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
|
||||
} else if (bareIndex < required.size) {
|
||||
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
|
||||
out[required[bareIndex++]] = f
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private val ALTER = Regex("[,&]")
|
||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
|
||||
parseToolCallArgs(raw, expected)
|
||||
|
||||
// ---------- диспач вызовов ----------
|
||||
|
||||
|
||||
@@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||
*
|
||||
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
||||
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
|
||||
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
|
||||
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
|
||||
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
|
||||
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
|
||||
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
|
||||
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
||||
* затем делает повторный вызов LLM. Невидимые системные тулы
|
||||
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
||||
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
|
||||
* пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
|
||||
* возвращается пользователю как финальный ответ.
|
||||
*/
|
||||
class Assistant(
|
||||
private val chatDao: ChatDao,
|
||||
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
||||
private val chat: suspend (history: List<ChatMessage>, tools: List<ToolSpec>, system: String?) -> LlmResult,
|
||||
private val toolkit: AgentToolkit? = null,
|
||||
private val tools: List<AgentTool> = AgentTools.ALL,
|
||||
private val nativeTools: Boolean = false,
|
||||
private val timeoutMs: Long = 30_000,
|
||||
) {
|
||||
/**
|
||||
@@ -50,7 +53,7 @@ class Assistant(
|
||||
|
||||
private suspend fun processInSession(sessionId: Long): String {
|
||||
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
||||
var answer = callLlm(history, sessionId)
|
||||
var result = callLlm(history, sessionId)
|
||||
var lastToolResult: String? = null
|
||||
var visibleCalls = 0
|
||||
var llmCalls = 1
|
||||
@@ -58,31 +61,31 @@ class Assistant(
|
||||
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
|
||||
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
|
||||
while (true) {
|
||||
val parsed = parseToolCall(answer)
|
||||
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
|
||||
val toolName = parsed.name
|
||||
val call = result.toolCalls.firstOrNull()
|
||||
?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
|
||||
val toolName = call.name
|
||||
if (toolkit == null) {
|
||||
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
|
||||
val tool = tools.firstOrNull { it.name == toolName }
|
||||
if (tool == null) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
|
||||
return "Тул $toolName не найден"
|
||||
}
|
||||
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, lastToolResult)
|
||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
continue
|
||||
}
|
||||
when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) {
|
||||
when (val dispatch = toolkit.dispatch(sessionId, toolName, call.arguments)) {
|
||||
is AgentToolkit.Dispatch.Run -> {
|
||||
lastToolResult = result.result
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, result.result)
|
||||
lastToolResult = dispatch.result
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, dispatch.result)
|
||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
}
|
||||
|
||||
@@ -92,29 +95,34 @@ class Assistant(
|
||||
// (переход в состояние 3 — «Активные тулсеты»).
|
||||
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
|
||||
if (llmCalls++ >= MAX_LLM_CALLS) break
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
|
||||
// чтобы пользователь получил данные, а не сырой маркер.
|
||||
val stillTool = parseToolCall(answer)
|
||||
// LLM зациклилась на тул-вызовах — отдаём последний результат тула как
|
||||
// ответ, чтобы пользователь получил данные, а не сырой маркер.
|
||||
val stillTool = result.toolCalls.firstOrNull()
|
||||
return if (stillTool != null && lastToolResult != null) {
|
||||
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
|
||||
} else {
|
||||
answer
|
||||
result.text
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
|
||||
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
|
||||
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
|
||||
?: return "Ассистент не ответил (таймаут)"
|
||||
if (!isToolCall(answer)) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
|
||||
val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
|
||||
val specs = if (toolkit != null) {
|
||||
toolkit.toolSpecs(sessionId)
|
||||
} else {
|
||||
tools.map { ToolSpec(name = it.name, description = it.description) }
|
||||
}
|
||||
val result = withTimeoutOrNull(timeoutMs) { chat(history, specs, system) }
|
||||
?: return LlmResult("Ассистент не ответил (таймаут)")
|
||||
if (result.toolCalls.isEmpty()) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
|
||||
chatDao.touchSession(sessionId)
|
||||
}
|
||||
return answer
|
||||
return result
|
||||
}
|
||||
|
||||
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
|
||||
@@ -131,23 +139,6 @@ class Assistant(
|
||||
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
|
||||
const val MAX_LLM_CALLS = 8
|
||||
|
||||
/**
|
||||
* Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
|
||||
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
|
||||
*/
|
||||
private val TOOL_CALL_RE =
|
||||
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
|
||||
|
||||
data class ParsedToolCall(val name: String, val argsRaw: String?)
|
||||
|
||||
private fun parseToolCall(answer: String): ParsedToolCall? {
|
||||
val m = TOOL_CALL_RE.find(answer) ?: return null
|
||||
val argsRaw = m.groupValues[2].trim().ifBlank { null }
|
||||
return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw)
|
||||
}
|
||||
|
||||
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
|
||||
|
||||
/** Случайное имя сессии (UUID-подобное, 8 символов). */
|
||||
private fun randomTitle(): String =
|
||||
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
||||
|
||||
@@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import pw.binom.viewmate.phone.log
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,106 @@ interface LlmClient {
|
||||
* [system] — системный промпт тулкита/настроек или null (не обязателен).
|
||||
*/
|
||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
|
||||
|
||||
/**
|
||||
* Чат с тул-каналом: [tools] — схемы доступных тул ([ToolSpec]).
|
||||
* Локальный клиент регистрирует их в движке (нативный LiteRT-канал),
|
||||
* удалённый игнорирует список (тулы описывает текстовый протокол в промпте).
|
||||
* Результат [LlmResult] несёт И текст, И структурированные вызовы
|
||||
* [LlmToolCall] — клиент нормализует источник (нативный / текстовый
|
||||
* маркер), [Assistant] потребляет оба бэкенда одинаково.
|
||||
*/
|
||||
suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec> = emptyList(),
|
||||
system: String? = null,
|
||||
): LlmResult = LlmResult(text = chat(messages, system))
|
||||
}
|
||||
|
||||
/**
|
||||
* Нейтральная схема тула для [LlmClient.chatWithTools]: имя, описание и
|
||||
* аргументы (та же нотация, что у [ToolSignature]: «query», «seek_ms?» —
|
||||
* «?» = опциональный).
|
||||
*/
|
||||
data class ToolSpec(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val args: List<String> = emptyList(),
|
||||
) {
|
||||
/** Обязательные аргументы (без маркера «?»). */
|
||||
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
|
||||
|
||||
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
|
||||
fun argNames(): List<String> = args.map { it.substringBefore('?') }
|
||||
}
|
||||
|
||||
/** Вызов тула, декларированный моделью: имя + распарсенные именованные аргументы. */
|
||||
data class LlmToolCall(
|
||||
val name: String,
|
||||
val arguments: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Ответ [LlmClient.chatWithTools]: текст + декларированные вызовы тул (могут быть пусты). */
|
||||
data class LlmResult(
|
||||
val text: String,
|
||||
val toolCalls: List<LlmToolCall> = emptyList(),
|
||||
)
|
||||
|
||||
// ---------- Текстовый протокол вызова тула (RemoteLlmClient) ----------
|
||||
|
||||
/**
|
||||
* Строка вызова тула в текстовом протоколе: `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
|
||||
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
|
||||
*/
|
||||
val TOOL_CALL_RE =
|
||||
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
||||
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
||||
* [expected] — имена аргументов схемы ([ToolSpec.argNames]).
|
||||
*/
|
||||
fun parseToolCallArgs(raw: String?, expected: List<String>): Map<String, String> {
|
||||
val s = raw?.trim()
|
||||
if (s.isNullOrEmpty()) return emptyMap()
|
||||
val required = expected.filterNot { it.endsWith("?") }.distinct()
|
||||
val out = LinkedHashMap<String, String>()
|
||||
var bareIndex = 0
|
||||
for (field in s.split(Regex("[,&]"))) {
|
||||
val f = field.trim()
|
||||
if (f.isEmpty()) continue
|
||||
val eq = f.indexOf('=')
|
||||
if (eq > 0) {
|
||||
val key = f.substring(0, eq).trim()
|
||||
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
|
||||
} else if (bareIndex < required.size) {
|
||||
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
|
||||
out[required[bareIndex++]] = f
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Лог длинного текста (тела запроса, промпта, ответа) в канал [log]
|
||||
* кусками ≤3500 символов — лимит записи logcat ~4096, длиннее обрезалось бы.
|
||||
*/
|
||||
internal fun logChunked(tag: String, label: String, text: String) {
|
||||
if (text.length <= 3500) {
|
||||
log(tag, "$label: $text")
|
||||
return
|
||||
}
|
||||
val size = 3500
|
||||
val total = (text.length + size - 1) / size
|
||||
var from = 0
|
||||
var i = 0
|
||||
while (from < text.length && i < total) {
|
||||
val to = minOf(from + size, text.length)
|
||||
log(tag, "$label [${i + 1}/$total]: ${text.substring(from, to)}")
|
||||
from = to
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +202,8 @@ class RemoteLlmClient(
|
||||
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
|
||||
|
||||
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
|
||||
log("llm", "remote: запрос: модель=$model, сообщений=${dto.size}")
|
||||
logChunked("llm", "remote: тело запроса", body)
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/v1/chat/completions")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
@@ -115,11 +218,34 @@ class RemoteLlmClient(
|
||||
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
||||
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
||||
}
|
||||
val text = runCatching {
|
||||
val reply = runCatching {
|
||||
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
||||
}.getOrNull()
|
||||
text?.trim() ?: ""
|
||||
val text = reply?.trim() ?: ""
|
||||
logChunked("llm", "remote: ответ", text.ifEmpty { "(пусто)" })
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстовый протокол: тулы описываются в системном промпте, модель отвечает
|
||||
* строкой `TOOL_CALL: имя|арг1=знач1` — маркер разбираем здесь и
|
||||
* нормализуем в [LlmToolCall] (схема аргументов — из [tools], «голое»
|
||||
* значение прилетает первому обязательному аргументу).
|
||||
*/
|
||||
override suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec>,
|
||||
system: String?,
|
||||
): LlmResult {
|
||||
val text = chat(messages, system)
|
||||
val m = TOOL_CALL_RE.find(text) ?: return LlmResult(text)
|
||||
val name = m.groupValues[1].lowercase()
|
||||
val argsRaw = m.groupValues[2].trim().ifBlank { null }
|
||||
val expected = tools.firstOrNull { it.name == name }?.argNames() ?: emptyList()
|
||||
val args = parseToolCallArgs(argsRaw, expected)
|
||||
log("llm", "remote: toolCall: $name (сырое='${argsRaw.orEmpty()}') → $args")
|
||||
return LlmResult(text, listOf(LlmToolCall(name, args)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import com.google.ai.edge.litertlm.Backend
|
||||
import com.google.ai.edge.litertlm.Content
|
||||
import com.google.ai.edge.litertlm.Contents
|
||||
import com.google.ai.edge.litertlm.Conversation
|
||||
import com.google.ai.edge.litertlm.ConversationConfig
|
||||
import com.google.ai.edge.litertlm.Engine
|
||||
import com.google.ai.edge.litertlm.EngineConfig
|
||||
import com.google.ai.edge.litertlm.ExperimentalApi
|
||||
import com.google.ai.edge.litertlm.ExperimentalFlags
|
||||
import com.google.ai.edge.litertlm.Message
|
||||
import pw.binom.litert.LiteBackend
|
||||
import pw.binom.litert.LiteConfig
|
||||
import pw.binom.litert.LiteConversation
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteExperimental
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.litert.LiteMessage
|
||||
import pw.binom.litert.LiteTool
|
||||
import pw.binom.litert.LiteToolCall
|
||||
import pw.binom.litert.google.googleLiteLlm
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
@@ -75,8 +82,12 @@ object Gemma4E2B {
|
||||
* Ключ: cacheDir должен быть системным applicationContext.cacheDir —
|
||||
* GPU-делегату (ml_drift) нужен настоящий кэш для компиляции шейдеров;
|
||||
* свой подкаталог в filesDir приводил к падению на delegate_kernel.cc.
|
||||
* Экспериментальный флаг: без enableSpeculativeDecoding статический
|
||||
* TopK-сэмплер на GPU требует OpenCL-драйвер, которого нет в Android
|
||||
* app-namespace («Can not find OpenCL library»); на HONOR работает
|
||||
* WebGPU-сэмплер, т.е. флаг включён.
|
||||
*/
|
||||
fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
|
||||
fun liteConfig(modelFile: File, cacheDir: File): LiteConfig = LiteConfig(
|
||||
modelPath = modelFile.absolutePath,
|
||||
// NOTE 2026-08-23 (porfiry): точь-в-точь конфиг автора (InferenceService.kt:485-497)
|
||||
// в референсе vayunmathur/Modern-Apps — GPU/GPU/CPU. Авторский бинарник на
|
||||
@@ -85,10 +96,11 @@ object Gemma4E2B {
|
||||
// vision-путь не трогает (буфер аллокируется только при картинке), поэтому
|
||||
// берём авторскую схему целиком. Если упёрнемся в RESOURCE_EXHAUSTED на
|
||||
// картинке — вернём vision на CPU точечно.
|
||||
backend = Backend.GPU(),
|
||||
visionBackend = Backend.GPU(),
|
||||
audioBackend = Backend.CPU(),
|
||||
backend = LiteBackend.GPU,
|
||||
visionBackend = LiteBackend.GPU,
|
||||
audioBackend = LiteBackend.CPU,
|
||||
cacheDir = cacheDir.absolutePath,
|
||||
experimental = LiteExperimental(enableSpeculativeDecoding = true),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,53 +127,53 @@ fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
|
||||
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
|
||||
class LocalLlmPrompt(
|
||||
val systemInstruction: String?,
|
||||
val initialMessages: List<Message>,
|
||||
val incoming: Message,
|
||||
val initialMessages: List<LiteMessage>,
|
||||
val incoming: LiteMessage,
|
||||
)
|
||||
|
||||
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
|
||||
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
|
||||
.joinToString("") { it.text }
|
||||
|
||||
/**
|
||||
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
|
||||
* - USER → [Message.user], ASSISTANT → [Message.model];
|
||||
* - USER → [LiteMessage.user], ASSISTANT → [LiteMessage.model];
|
||||
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
|
||||
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет
|
||||
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж);
|
||||
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (консервативно: общая
|
||||
* с remote-клиентом текстовая конвенция; нативная роль LiteRole.TOOL —
|
||||
* задел на будущее, когда шаблон модели будет уметь родные тулзы);
|
||||
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
|
||||
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
|
||||
* - пустые content отбрасываются.
|
||||
*
|
||||
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL:
|
||||
* оно уходит в sendMessageAsync, остальное — история (initialMessages).
|
||||
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL.
|
||||
* В RAW-режиме (контракт pw.binom.litert) владелец истории — потребитель:
|
||||
* каждый инференс — новый полный промпт, движок не несёт состояния между
|
||||
* вызовами; будущие механизмы чистки/сжатия истории для движка — просто
|
||||
* новый (короткий) полный промпт.
|
||||
* Если последние сообщения только модельные/история пуста — incoming = «Продолжай.»
|
||||
* (аналог fallback [RemoteLlmClient]).
|
||||
*/
|
||||
fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt {
|
||||
val systemParts = ArrayList<String>()
|
||||
system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
|
||||
val mapped = ArrayList<Message>()
|
||||
val mapped = ArrayList<LiteMessage>()
|
||||
for (msg in messages) {
|
||||
val text = msg.content
|
||||
when (msg.role) {
|
||||
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
|
||||
ChatRole.USER -> if (text.isNotBlank()) mapped += Message.user(Contents.of(text))
|
||||
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(text))
|
||||
ChatRole.USER -> if (text.isNotBlank()) mapped += LiteMessage.user(text)
|
||||
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += LiteMessage.model(text)
|
||||
ChatRole.TOOL -> if (text.isNotBlank()) {
|
||||
mapped += Message.user(Contents.of("Результат тула ${msg.toolCallId ?: "?"}: $text"))
|
||||
mapped += LiteMessage.user("Результат тула ${msg.toolCallId ?: "?"}: $text")
|
||||
}
|
||||
}
|
||||
}
|
||||
val last = messages.lastOrNull { it.content.isNotBlank() }
|
||||
val incoming: Message
|
||||
val initial: List<Message>
|
||||
val incoming: LiteMessage
|
||||
val initial: List<LiteMessage>
|
||||
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
|
||||
initial = mapped.dropLast(1)
|
||||
incoming = mapped.last()
|
||||
} else {
|
||||
initial = mapped
|
||||
incoming = Message.user(Contents.of("Продолжай."))
|
||||
incoming = LiteMessage.user("Продолжай.")
|
||||
}
|
||||
return LocalLlmPrompt(
|
||||
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
|
||||
@@ -170,6 +182,55 @@ fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): Lo
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI-JSON-дескриптор [ToolSpec] для LiteRT-LM: ПЛОСКИЙ объект
|
||||
* `{"name","description","parameters"}` — так его читает движок
|
||||
* (litertlm 0.16.1, ToolKt.tool: `json.get("name").getAsString()` на
|
||||
* верхнем уровне; OpenAI-обёртка `{"type":"function","function":{…}}`
|
||||
* падает ToolException «Failed to parse field name»). Движок передаёт
|
||||
* строку модели КАК ЕСТЬ — без репарсинга и валидации (контракт тул-слоя
|
||||
* pw.binom.litert, tag 5), поэтому JSON строим сами.
|
||||
* Все аргументы у нас строковые (текстовый протокол тулкита), `type` — «string».
|
||||
*/
|
||||
fun toolJson(spec: ToolSpec): String = buildJsonObject {
|
||||
put("name", spec.name)
|
||||
put("description", spec.description)
|
||||
putJsonObject("parameters") {
|
||||
put("type", "object")
|
||||
if (spec.argNames().isNotEmpty()) {
|
||||
putJsonObject("properties") {
|
||||
spec.args.forEach { a ->
|
||||
putJsonObject(a.substringBefore('?')) {
|
||||
put("type", "string")
|
||||
}
|
||||
}
|
||||
}
|
||||
val required = spec.requiredArgs()
|
||||
if (required.isNotEmpty()) {
|
||||
putJsonArray("required") {
|
||||
required.forEach { add(JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* Наш [ToolSpec] в виде [LiteTool] LiteRT-слоя: `describe()` — самособранное
|
||||
* OpenAPI-JSON (плоский, для LiteRT-LM) из [toolJson]. `invoke()` недостижим: в ручном режиме
|
||||
* (automaticToolCalling = false) тул-цикл ведёт [Assistant], движок не
|
||||
* исполняет тулы — если движок всё же позвал `invoke`, это баг движка,
|
||||
* падаем с понятным сообщением.
|
||||
*/
|
||||
private class SpecLiteTool(spec: ToolSpec) : LiteTool {
|
||||
private val descriptor: String = toolJson(spec)
|
||||
|
||||
override fun describe(): String = descriptor
|
||||
|
||||
override fun invoke(arguments: String): String =
|
||||
error("LiteTool.invoke: тул-цикл ведёт Assistant (automaticToolCalling = false), движок не должен исполнять тулы")
|
||||
}
|
||||
|
||||
// ---------- Скачивание модели ----------
|
||||
|
||||
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
|
||||
@@ -281,12 +342,15 @@ class GemmaModelDownloader(
|
||||
|
||||
/**
|
||||
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве
|
||||
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md).
|
||||
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md),
|
||||
* через KMP-библиотеку pw.binom.litert (универсальный контракт
|
||||
* RAW-инференса: владелец истории — потребитель).
|
||||
*
|
||||
* Жизненный цикл: [Engine] создаётся один раз (лениво, при первом [chat]),
|
||||
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
|
||||
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
||||
* новый Conversation (своей истории у LiteRT-LM нет — она в наших
|
||||
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским
|
||||
* новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
|
||||
* (системная инструкция + вся история + входящее) и схемы тул,
|
||||
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
|
||||
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
||||
* пользователю, падение приложения исключено.
|
||||
*
|
||||
@@ -298,96 +362,124 @@ class LocalLlmClient(
|
||||
private val cacheDir: File,
|
||||
) : LlmClient {
|
||||
|
||||
private val engineLock = Any()
|
||||
private val llmLock = Any()
|
||||
@Volatile
|
||||
private var engine: Engine? = null
|
||||
private var llm: LiteLlm? = null
|
||||
|
||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */
|
||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
|
||||
fun modelDownloaded(): Boolean = modelFile.isFile
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
|
||||
val engine = ensureEngine()
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
|
||||
chatWithTools(messages, emptyList(), system).text
|
||||
|
||||
/**
|
||||
* Чат с тул-каналом: [tools] регистрируются в движке нативным LiteRT-каналом
|
||||
* (JSON-схемы [toolJson] → модели; вызовы → [LlmResult.toolCalls]).
|
||||
* Ручной режим: движок тулы НЕ исполняет (automaticToolCalling = false) —
|
||||
* цикл «вызов → dispatch → результат в историю → повторный инференс»
|
||||
* ведёт [Assistant]; каждый круг — новый RAW-инференс.
|
||||
*/
|
||||
override suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec>,
|
||||
system: String?,
|
||||
): LlmResult {
|
||||
log("llm", "chat: вызов (messages=${messages.size}, tools=${tools.size}, system=${system?.length ?: 0} симв)")
|
||||
val llm = ensureLlm()
|
||||
log("llm", "chat: движок готов, строю промпт")
|
||||
val prompt = buildLocalLlmPrompt(messages, system)
|
||||
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text().take(60)}")
|
||||
val conversation: Conversation = synchronized(engineLock) {
|
||||
engine.createConversation(
|
||||
ConversationConfig(
|
||||
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) },
|
||||
initialMessages = prompt.initialMessages,
|
||||
)
|
||||
)
|
||||
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}")
|
||||
// Полный контент, уходящий в модель: системный промпт, история, входящее, схемы тул
|
||||
logChunked("llm", "local: system", prompt.systemInstruction ?: "(нет)")
|
||||
prompt.initialMessages.forEachIndexed { i, m ->
|
||||
logChunked("llm", "local: msg[$i/${prompt.initialMessages.size}] ${m.role}", m.text)
|
||||
}
|
||||
log("llm", "chat: conversation создан")
|
||||
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 {
|
||||
var text = ""
|
||||
var chunks = 0
|
||||
var tokens = 0
|
||||
val t0 = System.currentTimeMillis()
|
||||
var firstTokenAt = 0L
|
||||
withTimeout(120_000) {
|
||||
// NOTE 2026-08-23: sig 0.14.0 — sendMessageAsync(Message, Map), дефолт
|
||||
// emptyMap(). null-literal не компилируется (Map без @Nullable); в чужом
|
||||
// dex null — артефакт маски $default, не аргумент.
|
||||
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
|
||||
chunks++
|
||||
val chunkText = messageChunk.text()
|
||||
text += chunkText
|
||||
if (chunkText.isNotEmpty()) {
|
||||
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
|
||||
tokens += 1
|
||||
conversation = llm.createConversation(config)
|
||||
// Поток либы сам себя не завершает (callbackFlow живёт до отмены):
|
||||
// остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
|
||||
conversation.sendStream(prompt.incoming.text)
|
||||
.onEach { delta ->
|
||||
chunks++
|
||||
val chunkText = delta.text
|
||||
text += chunkText
|
||||
if (chunkText.isNotEmpty()) {
|
||||
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
|
||||
tokens += 1
|
||||
}
|
||||
if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls
|
||||
if (chunks <= 3 || chunks % 20 == 0) {
|
||||
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}${if (delta.toolCalls.isNotEmpty()) " [тулы: ${delta.toolCalls.map { it.name }}]" else ""}")
|
||||
}
|
||||
}
|
||||
if (chunks <= 3 || chunks % 20 == 0) {
|
||||
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}")
|
||||
}
|
||||
}
|
||||
.takeWhile { !it.isDone }
|
||||
.collect { }
|
||||
}
|
||||
val totalMs = System.currentTimeMillis() - t0
|
||||
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
|
||||
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
|
||||
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)")
|
||||
return text.trim()
|
||||
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
|
||||
val normalized = toolCalls.map { call ->
|
||||
LlmToolCall(
|
||||
name = call.name,
|
||||
arguments = call.arguments.mapValues { entry -> entry.value?.toString() ?: "" },
|
||||
)
|
||||
}
|
||||
logChunked("llm", "local: ответ", text.trim().ifEmpty { "(пусто)" })
|
||||
normalized.forEach { call ->
|
||||
log("llm", "local: toolCall: ${call.name} аргументы=${call.arguments}")
|
||||
}
|
||||
return LlmResult(text.trim(), normalized)
|
||||
} catch (e: CancellationException) {
|
||||
log("llm", "chat: отменено")
|
||||
runCatching { conversation.cancelProcess() }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
|
||||
log("llm", "chat: EXC msg=${sanitize(e.message)}")
|
||||
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
|
||||
} finally {
|
||||
runCatching { conversation.close() }
|
||||
// либа (RAW-путь) закрывает беседу сама, здесь — явный close для
|
||||
// прямого createConversation: каждый вызов — своя беседа
|
||||
runCatching { conversation?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
|
||||
fun close() {
|
||||
val eng = synchronized(engineLock) {
|
||||
val e = engine
|
||||
engine = null
|
||||
e
|
||||
val local = synchronized(llmLock) {
|
||||
val l = llm
|
||||
llm = null
|
||||
l
|
||||
}
|
||||
runCatching { eng?.close() }
|
||||
}
|
||||
|
||||
/** Точно как у автора (InferenceService.kt:486): включаем спекулятивное декодирование. */
|
||||
@OptIn(ExperimentalApi::class)
|
||||
private fun applyExperimentalFlags() {
|
||||
// NOTE 2026-08-23 (porfiry): ранее выключали спекуляцию, потому что статический
|
||||
// TopK-сэмплер при enableSpeculativeDecoding=false тянет OpenCL-драйвер,
|
||||
// которого нет в app-namespace ("Can not find OpenCL library"). Автор включил
|
||||
// её TRUE: тогда движок использует статический WebGPU-сэмплер (он на HONOR
|
||||
// работает — оригинал идёт, TTFB ~0.8с тёплый). Берём его флаг как есть.
|
||||
ExperimentalFlags.enableSpeculativeDecoding = true
|
||||
runCatching { local?.close() }
|
||||
}
|
||||
|
||||
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
|
||||
private fun ensureEngine(): Engine {
|
||||
val cached = engine
|
||||
private fun ensureLlm(): LiteLlm {
|
||||
val cached = llm
|
||||
if (cached != null) return cached
|
||||
return synchronized(engineLock) {
|
||||
val existing = engine
|
||||
return synchronized(llmLock) {
|
||||
val existing = llm
|
||||
if (existing != null) return existing
|
||||
if (!modelFile.isFile) {
|
||||
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
|
||||
@@ -395,21 +487,16 @@ class LocalLlmClient(
|
||||
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
|
||||
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
|
||||
}
|
||||
applyExperimentalFlags()
|
||||
// cacheDir должен СУЩЕСТВОВАТЬ: GPU-делегат (ml_drift) пишет
|
||||
// скомпилированные шейдеры в cacheDir; если каталога нет — движок
|
||||
// падает на executor.cc:1978 (delegate_kernel.cc:925). CPU-бэкенд
|
||||
// в кэш не пишет, поэтому работал и без mkdirs.
|
||||
cacheDir.mkdirs()
|
||||
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir))
|
||||
try {
|
||||
created.initialize()
|
||||
} catch (e: Exception) {
|
||||
runCatching { created.close() }
|
||||
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
|
||||
}
|
||||
log("llm", "локальный движок инициализирован: ${modelFile.name}")
|
||||
engine = created
|
||||
// Экспериментальные флаги (speculative decoding → WebGPU-сэмплер)
|
||||
// ушли в LiteConfig (см. [Gemma4E2B.liteConfig]).
|
||||
val created = googleLiteLlm(Gemma4E2B.liteConfig(modelFile, cacheDir))
|
||||
log("llm", "локальный движок готов: ${modelFile.name}")
|
||||
llm = created
|
||||
created
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,29 @@
|
||||
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
|
||||
import pw.binom.asr.qwen3.Qwen3Config
|
||||
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||
|
||||
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
|
||||
/** Офлайн-распознавание: asr-qwen3 (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 = "",
|
||||
convFrontendPath: String,
|
||||
encoderPath: String,
|
||||
decoderPath: String,
|
||||
tokenizerDir: String,
|
||||
numThreads: Int = 4,
|
||||
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",
|
||||
),
|
||||
/** Тяжёлая нативка создаётся один раз; 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 {
|
||||
val stream = recognizer.createStream()
|
||||
try {
|
||||
stream.acceptWaveform(samples, 16000)
|
||||
recognizer.decode(stream)
|
||||
return recognizer.getResult(stream).text.trim()
|
||||
} finally {
|
||||
stream.release()
|
||||
}
|
||||
}
|
||||
override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000)
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
|
||||
object SttDebug {
|
||||
|
||||
private const val TAG = "stt"
|
||||
|
||||
/**
|
||||
* Распознаёт [wavPath] по фразам (VAD + Whisper).
|
||||
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
|
||||
@@ -54,14 +51,14 @@ object SttDebug {
|
||||
log("[stt] ВЕСЬ ТЕКСТ: $all")
|
||||
all
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "[stt] ошибка: ${t.message}", t)
|
||||
log("[stt] ошибка: ${t.message}")
|
||||
t.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(msg: String) {
|
||||
Log.i(TAG, msg)
|
||||
pw.binom.viewmate.phone.log("stt", msg)
|
||||
println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,169 +6,11 @@ import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.sqrt
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
import pw.binom.viewmate.core.stt.splitPhrases
|
||||
import pw.binom.viewmate.core.stt.trimEdges
|
||||
import pw.binom.viewmate.phone.log
|
||||
|
||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||
val n = pcm.size / 2
|
||||
val out = FloatArray(n)
|
||||
var i = 0
|
||||
for (j in 0 until n) {
|
||||
val lo = pcm[i].toInt() and 0xff
|
||||
val hi = pcm[i + 1].toInt() and 0xff
|
||||
i += 2
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
out[j] = (s / 32768f).coerceIn(-1f, 1f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
|
||||
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
|
||||
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
|
||||
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
|
||||
*
|
||||
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
|
||||
* Чистая функция (без Android) — покрыта JVM-тестами.
|
||||
*/
|
||||
internal fun trimEdges(
|
||||
samples: FloatArray,
|
||||
threshold: Float = 0.01f,
|
||||
windowSize: Int = 320,
|
||||
padWindows: Int = 15,
|
||||
): FloatArray {
|
||||
if (samples.isEmpty()) return samples
|
||||
var first = -1
|
||||
var lastEnd = -1
|
||||
var idx = 0
|
||||
while (idx < samples.size) {
|
||||
val end = minOf(idx + windowSize, samples.size)
|
||||
var sum = 0.0
|
||||
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
|
||||
val rms = sqrt(sum / (end - idx)).toFloat()
|
||||
if (rms >= threshold) {
|
||||
if (first == -1) first = idx
|
||||
lastEnd = end
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
if (first == -1) return FloatArray(0)
|
||||
val pad = padWindows * windowSize
|
||||
val start = maxOf(0, first - pad)
|
||||
val stop = minOf(samples.size, lastEnd + pad)
|
||||
return samples.copyOfRange(start, stop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||
* НЕ менять без перепроверки на тех же дампах.
|
||||
*
|
||||
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
|
||||
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
|
||||
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
|
||||
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
|
||||
* каждой стороны (clamp к границам массива). Фраза короче
|
||||
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
|
||||
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
|
||||
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||
* < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
|
||||
*/
|
||||
internal fun splitPhrases(
|
||||
samples: FloatArray,
|
||||
threshold: Float = 0.005f,
|
||||
windowSize: Int = 320, // 20 мс @ 16 кГц
|
||||
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
||||
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
||||
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
||||
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
||||
): List<FloatArray> {
|
||||
if (samples.isEmpty()) return emptyList()
|
||||
val winCount = (samples.size + windowSize - 1) / windowSize
|
||||
val isSound = BooleanArray(winCount)
|
||||
var anySound = false
|
||||
for (w in 0 until winCount) {
|
||||
val s = w * windowSize
|
||||
val e = minOf(s + windowSize, samples.size)
|
||||
var sum = 0.0
|
||||
for (k in s until e) sum += samples[k] * samples[k].toDouble()
|
||||
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
|
||||
isSound[w] = true
|
||||
anySound = true
|
||||
}
|
||||
}
|
||||
if (!anySound) return emptyList()
|
||||
|
||||
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
|
||||
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
|
||||
class Phrase(var start: Int, var end: Int)
|
||||
|
||||
val phrases = mutableListOf<Phrase>()
|
||||
var pos = 0
|
||||
while (pos < winCount) {
|
||||
var sil = 0
|
||||
while (pos + sil < winCount && !isSound[pos + sil]) sil++
|
||||
if (pos + sil >= winCount) break
|
||||
val start = pos + sil
|
||||
var end = start
|
||||
var continuePhrase = true
|
||||
while (continuePhrase) {
|
||||
var run = 0
|
||||
while (end + run < winCount && isSound[end + run]) run++
|
||||
end += run
|
||||
if (end >= winCount) { continuePhrase = false; break }
|
||||
var sil2 = 0
|
||||
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
|
||||
if (sil2 >= minSilenceWindows) {
|
||||
continuePhrase = false
|
||||
} else {
|
||||
end += sil2
|
||||
}
|
||||
}
|
||||
phrases.add(Phrase(start, end))
|
||||
pos = end
|
||||
}
|
||||
|
||||
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
|
||||
while (phrases.size > 1) {
|
||||
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
|
||||
if (idx < 0) break
|
||||
val other = if (idx == 0) idx + 1 else idx - 1
|
||||
val lo = minOf(idx, other)
|
||||
val hi = maxOf(idx, other)
|
||||
val merged = Phrase(
|
||||
minOf(phrases[lo].start, phrases[hi].start),
|
||||
maxOf(phrases[lo].end, phrases[hi].end),
|
||||
)
|
||||
phrases.removeAt(hi)
|
||||
phrases.removeAt(lo)
|
||||
phrases.add(lo, merged)
|
||||
}
|
||||
|
||||
val pad = padWindows * windowSize
|
||||
val out = mutableListOf<FloatArray>()
|
||||
for (p in phrases) {
|
||||
val s = maxOf(0, p.start * windowSize - pad)
|
||||
val e = minOf(samples.size, p.end * windowSize + pad)
|
||||
if (e - s <= maxPhraseSamples) {
|
||||
out += samples.copyOfRange(s, e)
|
||||
} else {
|
||||
val chunk = (maxPhraseSamples / windowSize) * windowSize
|
||||
var cs = s
|
||||
while (cs < e) {
|
||||
val ce = minOf(e, cs + chunk)
|
||||
out += samples.copyOfRange(cs, ce)
|
||||
cs = ce
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -16,7 +18,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,6 +31,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -43,7 +49,10 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.WatchProgress
|
||||
import pw.binom.viewmate.phone.log
|
||||
|
||||
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
|
||||
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
|
||||
@@ -83,6 +92,8 @@ class CatalogState(
|
||||
private set
|
||||
var scrollOffset by mutableStateOf(0)
|
||||
private set
|
||||
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
|
||||
var onlyDownloaded by mutableStateOf(false)
|
||||
|
||||
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
|
||||
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
|
||||
@@ -195,7 +206,6 @@ class CatalogState(
|
||||
}
|
||||
}
|
||||
items = loaded
|
||||
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
@@ -204,6 +214,15 @@ class CatalogState(
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
// Зеркала — отдельный «мягкий» запрос: сбой/таймаут mirror.list не чистит
|
||||
// уже загруженный каталог, просто временно без бейджей.
|
||||
try {
|
||||
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("catalog", "зеркала: не удалось (${e.message}) — каталог без бейджей")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +249,36 @@ fun CatalogScreen(
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val userId by app.userId.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
// Системная кнопка «назад»: выход из вложенного пути (сериал→сезон, папки) на уровень выше
|
||||
BackHandler(enabled = state.canGoBack()) {
|
||||
state.back()
|
||||
}
|
||||
|
||||
// Все эпизоды сериала (для bulk-операций): по каждому сезону — список эпизодов.
|
||||
var seriesEpisodes by remember { mutableStateOf<List<JellyfinItem>?>(null) }
|
||||
val seasonIds = state.items.map { it.Id }
|
||||
LaunchedEffect(state.path, seasonIds, userId) {
|
||||
if (state.path !is CatalogState.Path.Series || userId.isBlank() || state.items.isEmpty()) {
|
||||
seriesEpisodes = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
seriesEpisodes = state.items.flatMap { season ->
|
||||
runCatching { app.jellyfin.items(userId, season.Id, listOf("Episode")) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
val bulkEpisodes = when (state.path) {
|
||||
is CatalogState.Path.Season -> state.items
|
||||
is CatalogState.Path.Series -> seriesEpisodes ?: emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
val bulkLabel = when (state.path) {
|
||||
is CatalogState.Path.Season -> "сезона"
|
||||
is CatalogState.Path.Series -> "сериала"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
|
||||
val gridState = rememberLazyGridState()
|
||||
@@ -247,6 +296,12 @@ fun CatalogScreen(
|
||||
}
|
||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
||||
val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
|
||||
// Статус видео на очках: скачанные itemId (свежий GlassesStatus) + живой прогресс
|
||||
// DownloadProgress (видео очки качают сами, звук — телефон).
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
// «Просмотрено» по элементам (watchprogress.json): тонкий бар на плашке + %/время у названия
|
||||
val watched by app.watchProgress.flow.collectAsState()
|
||||
|
||||
// подтянуть список, когда появился пользователь Jellyfin
|
||||
LaunchedEffect(userId) {
|
||||
@@ -255,12 +310,40 @@ fun CatalogScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
CatalogTopBar(state = state)
|
||||
if (bulkEpisodes.isNotEmpty()) {
|
||||
BulkActionsRow(
|
||||
episodes = bulkEpisodes,
|
||||
mirrors = state.mirrors,
|
||||
label = bulkLabel,
|
||||
contextName = state.subtitle(),
|
||||
onMessage = { message -> scope.launch { snackbar.showSnackbar(message) } },
|
||||
onStateRefresh = state::refresh,
|
||||
)
|
||||
}
|
||||
// фильтр «только скачанные (и частично)» — остаётся только то, что скачано на каком-то устройстве
|
||||
val visibleItems: List<JellyfinItem> = if (state.onlyDownloaded) {
|
||||
state.items.filter { item ->
|
||||
val id = item.Id
|
||||
downloaded.containsKey(id) ||
|
||||
localDownloaded.contains(id) ||
|
||||
glassesDownloadedIds.contains(id) ||
|
||||
glassesDownloads.containsKey(id)
|
||||
}
|
||||
} else {
|
||||
state.items
|
||||
}
|
||||
if (state.loading && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("загружаю…")
|
||||
} else if (state.error != null && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
||||
} else if (state.items.isEmpty()) {
|
||||
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
|
||||
} else if (visibleItems.isEmpty()) {
|
||||
ScreenPlaceholder(
|
||||
when {
|
||||
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
|
||||
state.query.isNotBlank() -> "ничего не нашлось"
|
||||
else -> "пусто"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
@@ -270,15 +353,20 @@ fun CatalogScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.items, key = { it.Id }) { item ->
|
||||
items(visibleItems, key = { it.Id }) { item ->
|
||||
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
|
||||
ItemCard(
|
||||
item = item,
|
||||
posterUrl = posterUrlOrNull(app, item),
|
||||
progress = watched[item.Id],
|
||||
badge = mirrorBadge(
|
||||
itemId = item.Id,
|
||||
job = state.mirrors[item.Id],
|
||||
downloaded = downloaded,
|
||||
localDownloaded = localDownloaded,
|
||||
glassesDownloading = videoProgress?.phase == DownloadPhase.DOWNLOADING,
|
||||
glassesDownloadingPercent = videoProgress?.percent,
|
||||
glassesDownloaded = item.Id in glassesDownloadedIds,
|
||||
),
|
||||
onClick = {
|
||||
when {
|
||||
@@ -303,6 +391,7 @@ fun CatalogScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
SnackbarHost(hostState = snackbar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +448,21 @@ private fun CatalogTopBar(state: CatalogState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.onlyDownloaded,
|
||||
onCheckedChange = { state.onlyDownloaded = it },
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = "только скачанные (и частично)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,9 +476,14 @@ private fun tabLabel(tab: CatalogTab): String = when (tab) {
|
||||
private fun ItemCard(
|
||||
item: JellyfinItem,
|
||||
posterUrl: String?,
|
||||
progress: WatchProgress?,
|
||||
badge: MirrorBadge,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// «Просмотрено»: % — когда известна длительность, иначе время; бар — снизу постера
|
||||
val percent = progress?.watchedPercent()
|
||||
val watchedLabel = progress?.takeIf { it.positionMs > 0L }
|
||||
?.let { p -> percent?.let { "$it%" } ?: msToClock(p.positionMs) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
@@ -392,6 +501,24 @@ private fun ItemCard(
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
if (percent != null && percent > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
|
||||
.background(PhoneLine),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(percent / 100f)
|
||||
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
|
||||
.background(PhoneAccent),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
@@ -408,13 +535,26 @@ private fun ItemCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.Name ?: item.Id,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = item.Name ?: item.Id,
|
||||
color = PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (watchedLabel != null) {
|
||||
Text(
|
||||
text = watchedLabel,
|
||||
color = PhoneMuted,
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -12,7 +13,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -20,7 +23,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -34,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -44,6 +53,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.agent.ChatMessage
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.Session
|
||||
@@ -61,7 +71,7 @@ fun ChatScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
|
||||
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) }
|
||||
var messages by remember { mutableStateOf<List<UiMsg>>(emptyList()) }
|
||||
var input by remember { mutableStateOf("") }
|
||||
var sessionMenu by remember { mutableStateOf(false) }
|
||||
var activeId by remember { mutableStateOf<Long?>(null) }
|
||||
@@ -74,7 +84,7 @@ fun ChatScreen() {
|
||||
val all = dao.listSessions()
|
||||
val target = activeId ?: all.firstOrNull()?.id
|
||||
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
|
||||
all to history.map { it.role to it.content }
|
||||
all to history.map(::uiMsgOf)
|
||||
}
|
||||
sessions = sess
|
||||
activeId = activeId ?: sess.firstOrNull()?.id
|
||||
@@ -102,7 +112,7 @@ fun ChatScreen() {
|
||||
delay(2_000)
|
||||
val id = activeId
|
||||
if (id != null) {
|
||||
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } }
|
||||
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map(::uiMsgOf) }
|
||||
if (fresh != messages) messages = fresh
|
||||
}
|
||||
withContext(Dispatchers.IO) { app.chatDao.listSessions() }.let { s -> if (s != sessions) sessions = s }
|
||||
@@ -176,7 +186,7 @@ fun ChatScreen() {
|
||||
TextButton(onClick = {
|
||||
app.resetChatSession()
|
||||
activeId?.let { id ->
|
||||
val history = app.chatDao.messagesForSession(id).map { it.role to it.content }
|
||||
val history = app.chatDao.messagesForSession(id).map(::uiMsgOf)
|
||||
messages = history
|
||||
}
|
||||
}) {
|
||||
@@ -231,8 +241,11 @@ fun ChatScreen() {
|
||||
)
|
||||
} else {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
items(messages) { (role, content) ->
|
||||
MessageBubble(role = role, text = content)
|
||||
items(messages) { msg ->
|
||||
when (msg.role) {
|
||||
ChatRole.TOOL -> ToolBubble(msg)
|
||||
else -> MessageBubble(role = msg.role, text = msg.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,7 +268,7 @@ fun ChatScreen() {
|
||||
val text = input.trim()
|
||||
if (text.isEmpty()) return@Button
|
||||
input = ""
|
||||
messages = messages + (ChatRole.USER to text)
|
||||
messages = messages + UiMsg(ChatRole.USER, text)
|
||||
app.sendToAssistant(text)
|
||||
},
|
||||
) {
|
||||
@@ -290,6 +303,71 @@ 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):
|
||||
* жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад.
|
||||
|
||||
@@ -31,11 +31,13 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import java.util.Locale
|
||||
|
||||
/** Экраны приложения (простая навигация без nav-библиотеки). */
|
||||
enum class Screen {
|
||||
CATALOG,
|
||||
DETAILS,
|
||||
ACTIVITY,
|
||||
GLASSES,
|
||||
GLASSES_INFO,
|
||||
CHAT,
|
||||
@@ -45,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
|
||||
|
||||
private val bottomTabs = listOf(
|
||||
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
|
||||
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
|
||||
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
|
||||
BottomTab(Screen.CHAT, "💬", "Чат"),
|
||||
)
|
||||
@@ -76,11 +79,13 @@ fun BottomNavBar(current: Screen, onSelect: (Screen) -> Unit) {
|
||||
data class MirrorBadge(val text: String, val bg: Color, val fg: Color)
|
||||
|
||||
/**
|
||||
* Статус зеркала элемента:
|
||||
* Статус зеркала элемента (коротко для бейджа каталога):
|
||||
* - ⟳ N% — конвертируется;
|
||||
* - ↓ часть — что-то скачано локально (телефон) или на очки;
|
||||
* - ✓ — зеркало готово;
|
||||
* - ✗ — ошибка;
|
||||
* - ✗ — ошибка конвертации;
|
||||
* - ↓ N% — видео скачивается на очки;
|
||||
* - ▶ — можно смотреть (видео уже на очках);
|
||||
* - ✓ — зеркало готово (ещё не на очках);
|
||||
* - ↓ часть — скачано что-то локально (телефон) или на очки;
|
||||
* - нет — ничего нет.
|
||||
*/
|
||||
fun mirrorBadge(
|
||||
@@ -88,6 +93,9 @@ fun mirrorBadge(
|
||||
job: MirrorJob?,
|
||||
downloaded: Map<String, String>,
|
||||
localDownloaded: Set<String>,
|
||||
glassesDownloading: Boolean = false,
|
||||
glassesDownloadingPercent: Int? = null,
|
||||
glassesDownloaded: Boolean = false,
|
||||
): MirrorBadge {
|
||||
val status = job?.status
|
||||
val hasLocal = localDownloaded.contains(itemId)
|
||||
@@ -101,6 +109,16 @@ fun mirrorBadge(
|
||||
|
||||
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
|
||||
|
||||
// Видео уже летит на очки — конкретнее «✓ готово»
|
||||
glassesDownloading -> MirrorBadge(
|
||||
"↓ ${glassesDownloadingPercent ?: 0}%",
|
||||
PhoneBlue,
|
||||
PhoneBlueText,
|
||||
)
|
||||
|
||||
// Видео на очках — можно смотреть
|
||||
glassesDownloaded -> MirrorBadge("▶", PhoneGreen, PhoneGreenText)
|
||||
|
||||
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
|
||||
|
||||
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
|
||||
@@ -168,7 +186,7 @@ fun bytesToSize(bytes: Long?): String {
|
||||
val gb = 1024.0 * 1024 * 1024
|
||||
val mb = 1024.0 * 1024
|
||||
return when {
|
||||
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
|
||||
bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
|
||||
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
|
||||
else -> "%d КБ".format((bytes / 1024).toLong())
|
||||
}
|
||||
@@ -291,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionLabel(text: String) {
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
color = PhoneMuted,
|
||||
@@ -299,6 +317,6 @@ fun SectionLabel(text: String) {
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
modifier = modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import pw.binom.viewmate.core.phone.trackFileName
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.FileDownloadState
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
@@ -79,16 +78,16 @@ fun DetailsScreen(
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
|
||||
|
||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
|
||||
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
|
||||
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
|
||||
LaunchedEffect(item.Id, refreshKey) {
|
||||
loading = true
|
||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
||||
loading = false
|
||||
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
|
||||
delay(3_000)
|
||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,13 +178,24 @@ fun DetailsScreen(
|
||||
}
|
||||
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам
|
||||
val localPath = "file://$mediaDir/${item.Id}/video.mkv"
|
||||
// «Продолжить с того места»: сохранённая позиция (null — не смотрели / досмотрели)
|
||||
val resumeMs = if (alreadyPlaying) null else app.watchProgress.resumePosition(item.Id)
|
||||
if (resumeMs != null) {
|
||||
log("watch", "возобновление с сохранённой позиции: ${resumeMs} мс (${item.Name})")
|
||||
}
|
||||
val result: String
|
||||
if (alreadyPlaying) {
|
||||
result = "включил (видео уже играет на очках)"
|
||||
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
|
||||
} else {
|
||||
result = try {
|
||||
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk)
|
||||
actions!!.watch(
|
||||
item.Id,
|
||||
effectiveAudioIndex,
|
||||
localVideoPath = localPath,
|
||||
phoneAudioReady = audioOk,
|
||||
startPositionMs = resumeMs ?: 0L,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log("watch", "watch упал: ${e.message}")
|
||||
snackbar.showSnackbar("watch упал: ${e.message}")
|
||||
@@ -211,7 +221,7 @@ fun DetailsScreen(
|
||||
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
|
||||
} else {
|
||||
log("watch", "audio source: ${audio.source} (${audio.reason})")
|
||||
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L
|
||||
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else (resumeMs ?: 0L)
|
||||
app.audioSync.play(audio.source, startPositionMs)
|
||||
}
|
||||
} else {
|
||||
@@ -392,26 +402,28 @@ fun DetailsScreen(
|
||||
keys.forEach { key ->
|
||||
val name = finalFileName(key)
|
||||
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
|
||||
val isVideo = key == "video"
|
||||
val phoneRaw = phoneFileState(finalFile, partFile)
|
||||
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
|
||||
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
|
||||
} else {
|
||||
phoneRaw.second
|
||||
}
|
||||
val glassesState = if (glassesFully) {
|
||||
FileDownloadState.DONE to 100
|
||||
} else {
|
||||
glassesFileState(perFileProgress[name])
|
||||
// Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
|
||||
val state = when {
|
||||
isVideo && glassesFully -> FileDownloadState.DONE to 100
|
||||
isVideo -> glassesFileState(perFileProgress[name])
|
||||
else -> phoneRaw.first to phonePercent
|
||||
}
|
||||
DownloadFileRow(
|
||||
name = name,
|
||||
sizeLabel = fileSizeOrProgress(
|
||||
sizeBytes = knownSizeByName[name],
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
),
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
device = if (isVideo) "очки" else "телефон",
|
||||
sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
|
||||
"⏳ ${state.second}%"
|
||||
} else {
|
||||
bytesToSize(knownSizeByName[name])
|
||||
},
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -498,24 +510,13 @@ fun DetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */
|
||||
private fun fileSizeOrProgress(
|
||||
sizeBytes: Long?,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
): String = when {
|
||||
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
|
||||
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
|
||||
else -> bytesToSize(sizeBytes)
|
||||
}
|
||||
|
||||
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
|
||||
/** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */
|
||||
@Composable
|
||||
private fun DownloadFileRow(
|
||||
name: String,
|
||||
device: String,
|
||||
sizeLabel: String,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
state: Pair<FileDownloadState, Int>,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
|
||||
@@ -523,13 +524,9 @@ private fun DownloadFileRow(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("телефон", color = PhoneMuted, fontSize = 9.sp)
|
||||
Text(device, color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(phone)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("очки", color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(glasses)
|
||||
FileChip(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,34 +5,61 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.phone.MercuryBridge
|
||||
|
||||
/**
|
||||
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
|
||||
* Минимум: найти очки → подключить → погасить/включить экран (deepSuspend).
|
||||
* Управление очками: подключение, экран, яркость и WiFi.
|
||||
*/
|
||||
@Composable
|
||||
fun MercuryPanel() {
|
||||
val scope = rememberCoroutineScope()
|
||||
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(
|
||||
modifier = Modifier
|
||||
@@ -43,7 +70,7 @@ fun MercuryPanel() {
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "🛰 Mercury BLE (полевой тест)",
|
||||
text = "🛰 Mercury BLE (управление очками)",
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -65,12 +92,7 @@ fun MercuryPanel() {
|
||||
|
||||
Row(modifier = Modifier.padding(top = 10.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
MercuryBridge.connectFirst()
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
onClick = { scope.launch { MercuryBridge.connectFirst() } },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
@@ -80,9 +102,7 @@ fun MercuryPanel() {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.disconnect() }
|
||||
},
|
||||
onClick = { scope.launch { MercuryBridge.disconnect() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
@@ -95,9 +115,67 @@ fun MercuryPanel() {
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.screenOff() }
|
||||
},
|
||||
onClick = { MercuryBridge.startPairing() },
|
||||
enabled = !state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneAccent,
|
||||
contentColor = Color(0xFF10251A),
|
||||
),
|
||||
) {
|
||||
Text("🔗 паринг (QR)", fontSize = 12.sp)
|
||||
}
|
||||
if (state.pairCode != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { MercuryBridge.cancelPairing() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text("отмена", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.pairClassicAuto() } },
|
||||
enabled = !state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF3A3F47),
|
||||
contentColor = Color(0xFFD5DBDF),
|
||||
),
|
||||
) {
|
||||
Text("🔗 паринг (вручную)", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
val pairCode = state.pairCode
|
||||
if (pairCode != null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Паринг: отсканируйте QR картинку камерой очков (она должна смотреть на экран телефона)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
QrCode(pairCode, side = 280, modifier = Modifier.padding(top = 8.dp))
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
"Код: $pairCode",
|
||||
color = PhoneText,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.screenOff() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2B2F36),
|
||||
@@ -108,9 +186,7 @@ fun MercuryPanel() {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.screenOn() }
|
||||
},
|
||||
onClick = { scope.launch { MercuryBridge.screenOn() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneAccent,
|
||||
@@ -120,5 +196,177 @@ fun MercuryPanel() {
|
||||
Text("☀️ включить экран", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Яркость ----
|
||||
Text(
|
||||
text = "Яркость: $brightness%",
|
||||
color = PhoneText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Slider(
|
||||
value = brightness.toFloat(),
|
||||
onValueChange = { brightness = it.roundToInt() },
|
||||
valueRange = 0f..100f,
|
||||
enabled = state.connected,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("Применить яркость", fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Switch(
|
||||
checked = autoBright,
|
||||
onCheckedChange = { nv ->
|
||||
autoBright = nv
|
||||
scope.launch { MercuryBridge.setAutoBrightness(nv) }
|
||||
},
|
||||
enabled = state.connected,
|
||||
)
|
||||
Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp)
|
||||
}
|
||||
|
||||
// ---- WiFi ----
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "WiFi",
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Switch(
|
||||
checked = wifiOn,
|
||||
onCheckedChange = { nv ->
|
||||
wifiOn = nv
|
||||
scope.launch { MercuryBridge.wifiToggle(nv) }
|
||||
},
|
||||
enabled = state.connected,
|
||||
)
|
||||
Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Button(
|
||||
onClick = { scope.launch {
|
||||
if (!state.wifiEnabled) MercuryBridge.wifiToggle(true)
|
||||
scanning = true
|
||||
delay(1500) // дать радио подняться
|
||||
MercuryBridge.wifiScan()
|
||||
delay(5000) // окно ожидания ответа очков
|
||||
scanning = false
|
||||
} },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp)
|
||||
}
|
||||
if (state.wifiSsid != null) {
|
||||
Text(
|
||||
"· ${state.wifiSsid}",
|
||||
color = PhoneBlueText,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = ssid,
|
||||
onValueChange = { ssid = it },
|
||||
label = { Text("SSID сети", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
enabled = state.connected,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
TextField(
|
||||
value = wifiPass,
|
||||
onValueChange = { wifiPass = it },
|
||||
label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
enabled = state.connected,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } },
|
||||
enabled = state.connected && ssid.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("Подключить к сети", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
if (state.wifiNetworks.isEmpty() && !scanning) {
|
||||
Text(
|
||||
"Список пуст: включи WiFi и нажми «Сканировать».",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.wifiNetworks.isNotEmpty()) {
|
||||
Text(
|
||||
"Найдено сетей: ${state.wifiNetworks.size}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
items(state.wifiNetworks) { net ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}",
|
||||
color = PhoneText,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { scope.launch {
|
||||
val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null
|
||||
MercuryBridge.wifiConnect(net.ssid, pwd)
|
||||
} },
|
||||
enabled = state.connected,
|
||||
) {
|
||||
Text("Подкл.", fontSize = 11.sp)
|
||||
}
|
||||
if (net.saved) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text("Забыть", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
|
||||
/**
|
||||
* Рендер QR-кода (pairCode) для паринга очков: очки сканируют его своей камерой.
|
||||
* В стоковом приложении RayNeo QR кодирует ровно pairCode (8 символов).
|
||||
*/
|
||||
@Composable
|
||||
fun QrCode(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
side: Int = 200,
|
||||
foreground: Color = Color.Black,
|
||||
background: Color = Color.White,
|
||||
) {
|
||||
val density = LocalDensity.current.density
|
||||
val sizePx = (side * density).toInt().coerceAtLeast(256)
|
||||
val bitmap = remember(text, sizePx, foreground, background) {
|
||||
renderQr(
|
||||
text = text,
|
||||
sizePx = sizePx,
|
||||
foreground = foreground.toArgb(),
|
||||
background = background.toArgb(),
|
||||
)
|
||||
}
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "QR-код паринга",
|
||||
modifier = modifier.size(side.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderQr(text: String, sizePx: Int, foreground: Int, background: Int): Bitmap {
|
||||
val bits = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
||||
val w = bits.width
|
||||
val h = bits.height
|
||||
val pixels = IntArray(w * h)
|
||||
for (y in 0 until h) {
|
||||
val rowOffset = y * w
|
||||
for (x in 0 until w) {
|
||||
pixels[rowOffset + x] = if (bits.get(x, y)) foreground else background
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
@@ -51,6 +51,8 @@ fun ViewMateApp() {
|
||||
},
|
||||
)
|
||||
|
||||
Screen.ACTIVITY -> ActivityScreen()
|
||||
|
||||
Screen.DETAILS -> {
|
||||
val item = selected
|
||||
if (item != null) {
|
||||
|
||||
@@ -12,58 +12,111 @@ import kotlin.test.assertTrue
|
||||
|
||||
class AudioSyncPlayerTest {
|
||||
|
||||
@Test
|
||||
fun noSeekWhenWithinThreshold() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_800, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs, "diff=800мс ≤ 1000мс — seek не нужен")
|
||||
assertNull(d.playing, "play/pause совпадают — не трогаем")
|
||||
}
|
||||
// --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek ---
|
||||
|
||||
@Test
|
||||
fun thresholdBoundaryDiff1000DoesNotSeek() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seeksToVideoPositionWhenDiffAboveThreshold() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
|
||||
fun noCorrectionWhenFullyAligned() {
|
||||
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, d.speed, "полное совпадение — темп не трогаем")
|
||||
assertNull(d.seekToMs)
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seeksWhenAudioAheadOfVideo() {
|
||||
val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
|
||||
fun speedStaysFlatInsideTriggerBand() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_100, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, d.speed, "diff=100мс внутри гистерезиса [-150;+150] — дребезга нет")
|
||||
assertNull(d.seekToMs)
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speedsUpWhenAudioLagsBehind() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_300, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f + 300f * DRIFT_GAIN, d.speed, "звук отстаёт — догоняем ускорением ${1f + 300f * DRIFT_GAIN}")
|
||||
assertNull(d.seekToMs, "diff=300мс внутри HARD-порога — seek не нужен")
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun slowsDownWhenAudioAhead() {
|
||||
val d = decideSync(audioPosition = 10_300, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f - 300f * DRIFT_GAIN, d.speed, "звук впереди — тормозим до ${1f - 300f * DRIFT_GAIN}")
|
||||
assertNull(d.seekToMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun smallDriftJustOutsideBandIsProportional() {
|
||||
val d = decideSync(audioPosition = 10_151, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f - 151f * DRIFT_GAIN, d.speed, "diff=151мс (чуть за зоной) — слабый пропорциональный темп")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speedSaturatesAtMaxDrift() {
|
||||
val behind = decideSync(audioPosition = 9_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f + MAX_DRIFT_SPEED, behind.speed, "diff=−1000мс → 1+0.2 упирается в потолок ${1f + MAX_DRIFT_SPEED}")
|
||||
val ahead = decideSync(audioPosition = 11_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f - MAX_DRIFT_SPEED, ahead.speed, "diff=+1000мс → 1−0.2 упирается в потолок ${1f - MAX_DRIFT_SPEED}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bandBoundaryExactly150MsStaysFlat() {
|
||||
val ahead = decideSync(audioPosition = 10_150, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, ahead.speed, "diff=+150мс — граница закрытой зоны, темп 1f")
|
||||
val behind = decideSync(audioPosition = 9_850, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(1f, behind.speed, "diff=-150мс — граница закрытой зоны, темп 1f")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardSeekWhenAudioFarBehind() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 16_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(16_000L, d.seekToMs, "diff=6000мс > 5000мс — жёсткий seek на позицию очков")
|
||||
assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко")
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardSeekWhenAudioFarAhead() {
|
||||
val d = decideSync(audioPosition = 26_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(20_000L, d.seekToMs)
|
||||
assertEquals(1f, d.speed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardThresholdBoundaryDoesNotSeek() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs, "diff=2000мс не БОЛЬШЕ порога — подгоняем скоростью, без рывка")
|
||||
assertEquals(1f + MAX_DRIFT_SPEED, d.speed, "diff=2000мс → 1+0.4 — упирается в потолок ${1f + MAX_DRIFT_SPEED}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardThresholdPlusOneSeeks() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 15_001, audioPlaying = true, videoPlaying = true)
|
||||
assertEquals(15_001L, d.seekToMs, "diff=5001мс > 5000мс — уже жёсткий seek")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playWhenVideoPlayingButAudioPaused() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true)
|
||||
assertNull(d.seekToMs)
|
||||
assertEquals(1f, d.speed, "play/pause влияние только на playing, не на скорость")
|
||||
assertEquals(true, d.playing, "очки играют — аудио должно играть")
|
||||
assertNull(d.seekToMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pauseWhenVideoPausedButAudioPlaying() {
|
||||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
|
||||
assertNull(d.seekToMs)
|
||||
assertEquals(1f, d.speed)
|
||||
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
|
||||
assertNull(d.seekToMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seekAndPlayApplyTogether() {
|
||||
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true)
|
||||
assertEquals(8_000L, d.seekToMs)
|
||||
val d = decideSync(audioPosition = 5_000, videoPosition = 15_000, audioPlaying = false, videoPlaying = true)
|
||||
assertEquals(15_000L, d.seekToMs)
|
||||
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noCorrectionWhenFullyAligned() {
|
||||
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
|
||||
assertNull(d.seekToMs)
|
||||
assertNull(d.playing)
|
||||
assertEquals(1f, d.speed)
|
||||
}
|
||||
|
||||
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import pw.binom.viewmate.phone.agent.ChatMessage
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** OpenAI-HTTP-сервер (Ktor): /v1/chat/completions (stream=false|true), маппинг ролей, ошибки. */
|
||||
class OpenAiHttpServerTest {
|
||||
|
||||
private val json = Json
|
||||
|
||||
private class FakeLlm(
|
||||
private val text: String = "Привет, мир!",
|
||||
private val failure: String? = null,
|
||||
) : LlmClient {
|
||||
var calls = 0
|
||||
var lastSystem: String? = null
|
||||
var lastMessages: List<ChatMessage> = emptyList()
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
calls++
|
||||
lastSystem = system
|
||||
lastMessages = messages
|
||||
failure?.let { throw IllegalStateException(it) }
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
private val chatBody =
|
||||
"""{"model":"any","stream":false,"messages":[{"role":"user","content":"Привет"}]}"""
|
||||
|
||||
private fun call(body: String, llm: LlmClient?): OpenAiHttpServer.ChatOutcome =
|
||||
runBlocking { OpenAiHttpServer.handleChat(llm, body) }
|
||||
|
||||
@Test
|
||||
fun nonStream_returnsCompletionJson() {
|
||||
val llm = FakeLlm()
|
||||
val outcome = call(chatBody, llm) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(200, outcome.code)
|
||||
val body = outcome.body
|
||||
assertTrue(body.contains("\"object\":\"chat.completion\""))
|
||||
assertTrue(body.contains("\"content\":\"Привет, мир!\""))
|
||||
assertTrue(body.contains("\"finish_reason\":\"stop\""))
|
||||
assertTrue(body.contains("\"id\":\"chatcmpl-"))
|
||||
assertEquals(1, llm.calls)
|
||||
assertEquals(listOf(ChatMessage.of(ChatRole.USER, "Привет")), llm.lastMessages)
|
||||
assertEquals(null, llm.lastSystem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemAndDeveloperRoles_goToSystem() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"system","content":"S1"},{"role":"developer","content":"S2"},{"role":"user","content":"U"},{"role":"assistant","content":"A"}]}""",
|
||||
llm,
|
||||
)
|
||||
assertEquals("S1\nS2", llm.lastSystem)
|
||||
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), llm.lastMessages.map { it.role })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolRole_mapsToToolMessage() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":"U"},{"role":"tool","tool_call_id":"t1","content":"результат"}]}""",
|
||||
llm,
|
||||
)
|
||||
val tool = llm.lastMessages.last()
|
||||
assertEquals(ChatRole.TOOL, tool.role)
|
||||
assertEquals("t1", tool.toolCallId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentAsArrayOfTextParts_joined() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":[{"type":"text","text":"часть1"},{"type":"text","text":"часть2"}]}]}""",
|
||||
llm,
|
||||
)
|
||||
assertEquals("часть1\nчасть2", llm.lastMessages.last().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantNullContentWithToolCalls_decodes() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":"U"},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}]}""",
|
||||
llm,
|
||||
)
|
||||
val roles = llm.lastMessages.map { it.role }
|
||||
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), roles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream_emitsSseChunksAndDone() {
|
||||
val llm = FakeLlm()
|
||||
val outcome = call(
|
||||
"""{"model":"any","stream":true,"messages":[{"role":"user","content":"Привет"}]}""",
|
||||
llm,
|
||||
) as OpenAiHttpServer.ChatOutcome.Sse
|
||||
val data = outcome.frames
|
||||
assertTrue(data.any { it.endsWith("[DONE]\n\n") || it == "data: [DONE]\n\n" })
|
||||
val chunks = data.filter { !it.contains("[DONE]") }.map {
|
||||
json.decodeFromString(JsonElement.serializer(), it.removePrefix("data: ").trim())
|
||||
}
|
||||
val choices = chunks.map { chunk ->
|
||||
chunk.jsonObject["choices"]!!.jsonArray[0].jsonObject
|
||||
}
|
||||
val deltas = choices.map { it["delta"]!!.jsonObject }
|
||||
assertEquals("assistant", deltas.first()["role"]!!.jsonPrimitive.content)
|
||||
val full = deltas.mapNotNull { it["content"]?.jsonPrimitive?.content }.joinToString("")
|
||||
assertEquals("Привет, мир!", full)
|
||||
assertEquals("stop", choices.last()["finish_reason"]!!.jsonPrimitive.content)
|
||||
assertTrue(chunks.all { it.jsonObject["object"]!!.jsonPrimitive.content == "chat.completion.chunk" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidBody_400() {
|
||||
val outcome = call("не json", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyMessages_400() {
|
||||
val outcome = call("""{"messages":[]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRole_400() {
|
||||
val outcome = call("""{"messages":[{"role":"alien","content":"hi"}]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noLlm_503() {
|
||||
val outcome = call(chatBody, null) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(503, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun llmFailure_502() {
|
||||
val outcome = call(chatBody, FakeLlm(failure = "boom")) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(502, outcome.code)
|
||||
assertTrue(outcome.body.contains("boom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allIps_containsLoopback() {
|
||||
assertTrue(OpenAiHttpServer.allIps().contains("127.0.0.1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class WatchProgressStoreTest {
|
||||
|
||||
private fun tempDir(name: String): File = createTempDirectory(name).toFile()
|
||||
|
||||
private fun newStore(): Pair<WatchProgressStore, File> {
|
||||
val dir = tempDir("watch-progress")
|
||||
return WatchProgressStore(dir) to dir
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsert and resume position`() {
|
||||
val (store, _) = newStore()
|
||||
store.upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
|
||||
|
||||
assertEquals(3_600_000L, store.resumePosition("item-1"))
|
||||
assertEquals(50, store.watchedPercent("item-1"))
|
||||
assertNull(store.resumePosition("other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume returns null for short or finished viewing`() {
|
||||
val (store, _) = newStore()
|
||||
store.upsert("short", positionMs = 10_000L, durationMs = 7_200_000L)
|
||||
assertNull(store.resumePosition("short"))
|
||||
|
||||
val finishedMs = (7_200_000L * WatchProgressStore.FINISHED_RATIO).toLong() + 1000L
|
||||
store.upsert("finished", positionMs = finishedMs, durationMs = 7_200_000L)
|
||||
assertNull(store.resumePosition("finished"))
|
||||
|
||||
val almost = 7_200_000L * 0.90
|
||||
store.upsert("almost", positionMs = almost.toLong(), durationMs = 7_200_000L)
|
||||
assertNotNull(store.resumePosition("almost"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backward and duplicate positions are not written`() {
|
||||
val (store, dir) = newStore()
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||
val writtenAfterAdvance = File(dir, "watchprogress.json").readText()
|
||||
|
||||
store.upsert("item-1", positionMs = 55_000L, durationMs = 7_200_000L)
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||
assertEquals(writtenAfterAdvance, File(dir, "watchprogress.json").readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `position beyond duration is rejected`() {
|
||||
val (store, dir) = newStore()
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||
val before = File(dir, "watchprogress.json").readText()
|
||||
|
||||
store.upsert("item-1", positionMs = 99_000_000L, durationMs = 7_200_000L)
|
||||
assertEquals(before, File(dir, "watchprogress.json").readText())
|
||||
assertEquals(60_000L, store.resumePosition("item-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration discovered later is persisted`() {
|
||||
val (store, dir) = newStore()
|
||||
store.upsert("item-1", positionMs = 60_000L)
|
||||
assertNull(store.watchedPercent("item-1"))
|
||||
|
||||
store.upsert("item-1", positionMs = 60_000L, durationMs = 120_000L)
|
||||
assertEquals(50, store.watchedPercent("item-1"))
|
||||
assertEquals("watchprogress.json", File(dir, "watchprogress.json").name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress survives reload`() {
|
||||
val dir = tempDir("watch-progress-reload")
|
||||
WatchProgressStore(dir).upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
|
||||
|
||||
val reloaded = WatchProgressStore(dir)
|
||||
assertEquals(3_600_000L, reloaded.resumePosition("item-1"))
|
||||
assertEquals(50, reloaded.watchedPercent("item-1"))
|
||||
assertEquals(mapOf("item-1" to WatchProgress(3_600_000L, 7_200_000L)), reloaded.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown duration gives percent null but resume works`() {
|
||||
val (store, _) = newStore()
|
||||
store.upsert("item-1", positionMs = 3_600_000L)
|
||||
assertNull(store.watchedPercent("item-1"))
|
||||
assertEquals(3_600_000L, store.resumePosition("item-1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Тесты тул-канала на границе клиента: парсер текстового маркера
|
||||
* (remote) и OpenAI-JSON-описатель тула (local, нативный LiteRT-канал).
|
||||
*/
|
||||
class LlmClientTest {
|
||||
|
||||
// ---------- parseToolCallArgs (текстовый протокол) ----------
|
||||
|
||||
@Test
|
||||
fun parseArgsEmptyWhenNoArgs() {
|
||||
assertEquals(emptyMap<String, String>(), parseToolCallArgs(null, emptyList()))
|
||||
assertEquals(emptyMap<String, String>(), parseToolCallArgs("", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsNamedAndBare() {
|
||||
val args = parseToolCallArgs("query=old songs,seek_ms=30000", listOf("query", "seek_ms?"))
|
||||
assertEquals("old songs", args["query"])
|
||||
assertEquals("30000", args["seek_ms"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsBareValueGoesToFirstExpected() {
|
||||
val args = parseToolCallArgs("old songs", listOf("query"))
|
||||
assertEquals("old songs", args["query"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsDuplicateNameLastWins() {
|
||||
val args = parseToolCallArgs("id=1,id=2", listOf("id"))
|
||||
assertEquals("2", args["id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsUnknownNamePassedThrough() {
|
||||
val args = parseToolCallArgs("id=1,foo=bar", listOf("id"))
|
||||
assertEquals("1", args["id"])
|
||||
assertTrue(args.containsKey("foo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsQuotedValueKeepsQuotes() {
|
||||
// Парсер прощающий: кавычки — литеральные символы, пары режутся запятой
|
||||
val args = parseToolCallArgs("""a="hello world",b=1""", listOf("a", "b"))
|
||||
assertEquals("\"hello world\"", args["a"])
|
||||
assertEquals("1", args["b"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsUnknownFormatGoesToFirstRequired() {
|
||||
// Неизвестный формат (без `=`) не падает: всё сырое значение
|
||||
// уходит первому обязательному аргументу
|
||||
val args = parseToolCallArgs("""{"id":"c42"}""", listOf("id"))
|
||||
assertEquals("""{"id":"c42"}""", args["id"])
|
||||
}
|
||||
|
||||
// ---------- TOOL_CALL-маркер (текстовый протокол, remote) ----------
|
||||
|
||||
@Test
|
||||
fun markerFoundWithArgs() {
|
||||
val m = TOOL_CALL_RE.find("хорошо, TOOL_CALL: media_search|query=old songs,seek_ms=30000")
|
||||
assertNotNull(m)
|
||||
assertEquals("media_search", m.groupValues[1])
|
||||
assertEquals("query=old songs,seek_ms=30000", m.groupValues[2].trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerFoundWithoutArgs() {
|
||||
val m = TOOL_CALL_RE.find("TOOL_CALL: get_current_time")
|
||||
assertNotNull(m)
|
||||
assertEquals("get_current_time", m.groupValues[1])
|
||||
assertTrue(m.groupValues[2].isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerNotFoundInPlainText() {
|
||||
assertNull(TOOL_CALL_RE.find("Какое сейчас время?"))
|
||||
}
|
||||
|
||||
// ---------- ToolSpec ----------
|
||||
|
||||
@Test
|
||||
fun specArgNamesAndRequired() {
|
||||
val spec = ToolSpec("media_search", "поиск", listOf("query", "seek_ms?"))
|
||||
assertEquals(listOf("query", "seek_ms"), spec.argNames())
|
||||
assertEquals(listOf("query"), spec.requiredArgs())
|
||||
}
|
||||
|
||||
// ---------- toolJson (нативный LiteRT-канал) ----------
|
||||
|
||||
@Test
|
||||
fun toolJsonHasFunctionFields() {
|
||||
val json = toolJson(ToolSpec("media_search", "поиск трека", listOf("query", "seek_ms?")))
|
||||
val obj = Json.parseToJsonElement(json) as JsonObject
|
||||
assertEquals("media_search", (obj["name"] as JsonPrimitive).content)
|
||||
val params = obj["parameters"] as JsonObject
|
||||
val props = params["properties"] as JsonObject
|
||||
assertTrue(props.containsKey("query"))
|
||||
assertTrue(props.containsKey("seek_ms"))
|
||||
val required = params["required"] as JsonArray
|
||||
assertEquals(listOf("query"), required.map { (it as JsonPrimitive).content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolJsonWithoutArgsHasNoProperties() {
|
||||
val json = toolJson(ToolSpec("get_current_time", "текущее время"))
|
||||
val obj = Json.parseToJsonElement(json) as JsonObject
|
||||
val params = obj["parameters"] as JsonObject
|
||||
assertFalse(params.containsKey("properties"))
|
||||
assertFalse(params.containsKey("required"))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import com.google.ai.edge.litertlm.Contents
|
||||
import com.google.ai.edge.litertlm.Message
|
||||
import pw.binom.litert.LiteMessage
|
||||
import pw.binom.litert.LiteRole
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.io.path.createTempFile
|
||||
@@ -47,12 +47,12 @@ class LlmLocalTest {
|
||||
// системный промпт = SYSTEM-сообщения (системный параметр не задан)
|
||||
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
|
||||
// входящее — последнее непустое USER
|
||||
assertEquals("погоди, выключи", p.incoming.text())
|
||||
assertEquals("погоди, выключи", p.incoming.text)
|
||||
// история: user, assistant, tool-результат в «Результат тула…» формате
|
||||
assertEquals(3, p.initialMessages.size)
|
||||
assertEquals("включи свет", p.initialMessages[0].text())
|
||||
assertEquals("Свет включён.", p.initialMessages[1].text())
|
||||
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text())
|
||||
assertEquals("включи свет", p.initialMessages[0].text)
|
||||
assertEquals("Свет включён.", p.initialMessages[1].text)
|
||||
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,7 +64,7 @@ class LlmLocalTest {
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
|
||||
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
|
||||
assertEquals("привет", p.incoming.text())
|
||||
assertEquals("привет", p.incoming.text)
|
||||
assertTrue(p.initialMessages.isEmpty())
|
||||
}
|
||||
|
||||
@@ -77,10 +77,10 @@ class LlmLocalTest {
|
||||
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
assertEquals("Результат тула c7: найдено 5", p.incoming.text())
|
||||
assertEquals("Результат тула c7: найдено 5", p.incoming.text)
|
||||
// assistant с пустым текстом в историю не попадает
|
||||
assertEquals(1, p.initialMessages.size)
|
||||
assertEquals("что в каталоге?", p.initialMessages[0].text())
|
||||
assertEquals("что в каталоге?", p.initialMessages[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,7 +90,7 @@ class LlmLocalTest {
|
||||
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
assertEquals("Продолжай.", p.incoming.text())
|
||||
assertEquals("Продолжай.", p.incoming.text)
|
||||
assertEquals(2, p.initialMessages.size)
|
||||
assertTrue(p.systemInstruction == null)
|
||||
}
|
||||
@@ -105,13 +105,15 @@ class LlmLocalTest {
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
// пустое user-сообщение отброшено; входящее — «как дела»
|
||||
assertEquals(1, p.initialMessages.size)
|
||||
assertEquals("как дела", p.incoming.text())
|
||||
assertEquals("как дела", p.incoming.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageTextExtractsTextParts() {
|
||||
val m = Message.user(Contents.of("привет"))
|
||||
assertEquals("привет", m.text())
|
||||
fun liteMessageFactoriesMapRoles() {
|
||||
assertEquals(LiteMessage(LiteRole.USER, "привет"), LiteMessage.user("привет"))
|
||||
assertEquals(LiteMessage(LiteRole.MODEL, "здравствуйте"), LiteMessage.model("здравствуйте"))
|
||||
assertEquals(LiteMessage(LiteRole.SYSTEM, "ты ассистент"), LiteMessage.system("ты ассистент"))
|
||||
assertEquals(LiteMessage(LiteRole.TOOL, "результат"), LiteMessage.tool("результат"))
|
||||
}
|
||||
|
||||
// ---------- SHA-256 ----------
|
||||
|
||||
@@ -3,6 +3,7 @@ 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): резать буфер на
|
||||
|
||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
|
||||
class S16ToFloatTest {
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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): краевая тишина убирается,
|
||||
|
||||
@@ -14,3 +14,13 @@ allprojects {
|
||||
}
|
||||
group = "pw.binom.viewmate"
|
||||
}
|
||||
|
||||
// Собери оба приложения и подпиши ключом Binom (v2+v3) одним шагом.
|
||||
// Кестор/креды берутся из скилла android-sign (см. signingConfigs в build.gradle.kts каждого app-модуля);
|
||||
// без кестора release соберётся без подписи.
|
||||
tasks.register("assembleSigned") {
|
||||
group = "build"
|
||||
description = "Собирает app-phone и app-glasses release-APK, подписанные ключом Binom (v2+v3)."
|
||||
dependsOn(":app-phone:assembleRelease", ":app-glasses:assembleRelease")
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ androidx-activity-compose = "1.10.1"
|
||||
exoplayer = "2.17.1"
|
||||
media3 = "1.6.1"
|
||||
koog = "1.1.1"
|
||||
litertlm-android = "0.14.0"
|
||||
litert-google = "5"
|
||||
|
||||
[libraries]
|
||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||
@@ -24,9 +24,15 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
|
||||
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
|
||||
# LiteRT LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
|
||||
# litertlm-android 0.14.0 нужен на kotlinx-coroutines 1.11.0 (shared версия поднята)
|
||||
litertlm-android = { module = "com.google.ai.edge.litertlm:litertlm-android", version.ref = "litertlm-android" }
|
||||
# LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
|
||||
# KMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp):
|
||||
# универсальный контракт (LiteLlm/LiteRequest — RAW-инференс, владелец истории —
|
||||
# потребитель) + нативный тул-слой (LiteTool/LiteToolCall, tag 5) + google-бэкенд
|
||||
# над официальным litertlm:0.16.1 (транзитивно).
|
||||
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert-google" }
|
||||
# Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются
|
||||
# приложением напрямую; в litert-google он runtime-only, поэтому явный.
|
||||
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert-google" }
|
||||
|
||||
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import java.io.File
|
||||
|
||||
class FileLogSpool(
|
||||
dir: String,
|
||||
maxFiles: Int,
|
||||
) : LogSpool {
|
||||
private val filesDir: File = File(dir).also { if (!it.exists()) it.mkdirs() }
|
||||
private val limit = maxFiles
|
||||
|
||||
/** Сериал следующих файлов: продолжение после старейшего существующего. */
|
||||
private var seq: Long = currentFiles().mapNotNull { it.seqNumber() }.maxOrNull() ?: 0L
|
||||
|
||||
override fun write(batch: LogBatch) {
|
||||
seq += 1
|
||||
File(filesDir, "$PREFIX$seq.json").writeText(LogBatch.encodeBatch(batch))
|
||||
trim()
|
||||
}
|
||||
|
||||
override fun peek(): LogBatch? {
|
||||
var file = oldestFile()
|
||||
while (file != null) {
|
||||
val batch = decodeQuiet(file)
|
||||
if (batch != null) return batch
|
||||
file.delete() // битый файл не должен затыкать очередь
|
||||
file = oldestFile()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun take(): LogBatch? {
|
||||
var file = oldestFile()
|
||||
while (file != null) {
|
||||
val batch = decodeQuiet(file)
|
||||
file.delete()
|
||||
if (batch != null) return batch
|
||||
file = oldestFile()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun pendingCount(): Int = currentFiles().size
|
||||
|
||||
private fun oldestFile(): File? = currentFiles().minByOrNull { it.seqNumber() }
|
||||
|
||||
private fun currentFiles(): List<File> = filesDir
|
||||
.listFiles { f -> f.isFile && f.name.startsWith(PREFIX) && f.name.endsWith(".json") }
|
||||
?.toList()
|
||||
?: emptyList()
|
||||
|
||||
private fun File.seqNumber(): Long =
|
||||
name.substringAfter(PREFIX).removeSuffix(".json").toLongOrNull() ?: 0L
|
||||
|
||||
private fun decodeQuiet(file: File): LogBatch? =
|
||||
runCatching { LogBatch.decodeBatch(file.readText()) }.getOrNull()
|
||||
|
||||
/** Потолок: выбросить старейшие файлы сверх [limit]. */
|
||||
private fun trim() {
|
||||
val files = currentFiles().sortedBy { it.seqNumber() }
|
||||
if (files.size <= limit) return
|
||||
files.take(files.size - limit).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFIX = "logspool-"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
|
||||
actual class LogLock {
|
||||
private val delegate = ReentrantLock()
|
||||
|
||||
actual fun <T> withLock(action: () -> T): T {
|
||||
delegate.lock()
|
||||
return try {
|
||||
action()
|
||||
} finally {
|
||||
delegate.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
actual class SystemClock : LogClock {
|
||||
override fun nowMillis(): Long = System.currentTimeMillis()
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
package pw.binom.viewmate.core
|
||||
|
||||
/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог общего слоя (KMP): без java.time, просто тег + сообщение.
|
||||
*
|
||||
* Приложение подключает свой коллектор через [logCollector] (в onCreate),
|
||||
* и каждый вызов [log] попадает в него (дальше — в спул / на сервер / Loki).
|
||||
* По умолчанию коллектор не задан — лог только в println.
|
||||
*/
|
||||
var logCollector: BatchingLogCollector? = null
|
||||
|
||||
fun log(tag: String, message: String) {
|
||||
println("[view-mate] $tag $message")
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Окно приёма Loki: записи старше этого возраста (или из будущего) отбрасываем. */
|
||||
private const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* «Абстрактный коллектор» лог-пачек: собирает записи в батчи и по времени
|
||||
* шлёт их «вниз» через [sink] (абстракция следующего коллектора).
|
||||
*
|
||||
* Смысл:
|
||||
* - [record]/[recordBatch] — синхронно, с любой нитки: запись в буфер (лок),
|
||||
* при переполнении [maxBatchEntries] — немедленный flush;
|
||||
* - таймер (период [flushIntervalMs]) — собирает буфер в [LogBatch] и шлёт в [sink];
|
||||
* - «не получилось послать» ([LogSink.send] = false / бросил) — пачка
|
||||
* **прихраняется в [spool]** и на каждом следующем тике ретраится ДО новых
|
||||
* пачек (FIFO: старое раньше);
|
||||
* - «чужие» записи (например, логи очков, пришедшие на телефон) — [recordBatch]
|
||||
* с сохранением их [LogEntry.source]/[LogEntry.ts].
|
||||
*
|
||||
* Поток: буфер — из любых ниток (под [LogLock]); спул и отправка — только
|
||||
* таймер-корутина (однопоточно).
|
||||
*/
|
||||
class BatchingLogCollector(
|
||||
/** Устройство-носитель коллектора: "glasses" / "phone". */
|
||||
val device: String,
|
||||
/** «Следующий» коллектор: куда шлём пачки. */
|
||||
private val sink: LogSink,
|
||||
/** Куда прихраним не отправленные пачки (retry позже). */
|
||||
private val spool: LogSpool,
|
||||
/** Часы для меток времени записей. */
|
||||
private val clock: LogClock,
|
||||
/** Период отправки пачек, мс. */
|
||||
private val flushIntervalMs: Long = 30_000L,
|
||||
/** Лимит записей в одной пачке — превышение триггерит немедленный flush. */
|
||||
private val maxBatchEntries: Int = 500,
|
||||
) {
|
||||
private val buffer = ArrayList<LogEntry>()
|
||||
private val lock = LogLock()
|
||||
|
||||
/** Потолок буфера в памяти (страховка, если sink долго не принимал). */
|
||||
private val maxBuffer: Int = maxBatchEntries * 20
|
||||
|
||||
@Volatile
|
||||
private var timerJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
/** Записать свою лог-строку (синхронно, с любой нитки). */
|
||||
fun record(tag: String, message: String) {
|
||||
recordBatch(listOf(LogEntry(clock.nowMillis(), tag, message, device)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Прибавить «чужие» записи (готовые [LogEntry] — со своими ts/source):
|
||||
* телефон так пересылает логи очков.
|
||||
*/
|
||||
fun recordBatch(entries: List<LogEntry>) {
|
||||
if (entries.isEmpty()) return
|
||||
val overflowed = lock.withLock {
|
||||
buffer.addAll(entries)
|
||||
val drop = buffer.size - maxBuffer
|
||||
if (drop > 0) repeat(drop) { buffer.removeAt(0) }
|
||||
buffer.size >= maxBatchEntries
|
||||
}
|
||||
if (overflowed) scope?.launch { flush() }
|
||||
}
|
||||
|
||||
/** Запустить таймер: каждые [flushIntervalMs] — flush (спул-ретраи + новый батч). */
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (timerJob != null) return
|
||||
this.scope = scope
|
||||
timerJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(flushIntervalMs)
|
||||
runCatching { flush() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Остановить таймер (буфер/спул не трогаются — дошлёт следующий запуск). */
|
||||
fun stop() {
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
scope = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Цикл отправки:
|
||||
* 1) ретраи спула — только старейшая пачка; не слано — ждём следующий тик
|
||||
* (новый батч не «перескакивает» через неё, порядок сохраняется);
|
||||
* 2) новый батч из буфера; не слано — прихраняем в [spool].
|
||||
*/
|
||||
suspend fun flush() {
|
||||
// Ретраи спула: выгребаем ВСЕ готовые пачки за тик (FIFO), а не одну.
|
||||
// Записи с «неприемлемым» таймстемпом (старше окна Loki ~7 дней или из
|
||||
// будущего) отбрасываем — иначе одна такая пачка топит весь пуш (Loki
|
||||
// отвечает 400 на весь batch) и спул никогда не чистится.
|
||||
var spooled = spool.peek()
|
||||
while (spooled != null) {
|
||||
val cleaned = clean(spooled)
|
||||
val ok = if (cleaned.entries.isEmpty()) true else sendBatch(cleaned)
|
||||
if (ok) {
|
||||
spool.take()
|
||||
spooled = spool.peek()
|
||||
} else {
|
||||
// спул не ушёл — буфер не трогаем, ждём следующий тик
|
||||
return
|
||||
}
|
||||
}
|
||||
val batch = drainBuffer() ?: return
|
||||
val cleaned = clean(batch)
|
||||
if (cleaned.entries.isNotEmpty()) {
|
||||
if (!sendBatch(cleaned)) spool.write(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбросить записи вне окна приёма Loki: старше [RETENTION_MS] или из будущего.
|
||||
* Такие записи Loki всё равно отверг бы всем батчем (HTTP 400), поэтому их
|
||||
* безопасно выкинуть — они не могут попасть в хранилище.
|
||||
*/
|
||||
private fun clean(batch: LogBatch): LogBatch {
|
||||
val now = clock.nowMillis()
|
||||
val min = now - RETENTION_MS
|
||||
val max = now + 3_600_000L
|
||||
val kept = batch.entries.filter { it.ts in min..max }
|
||||
return if (kept.size == batch.entries.size) batch else batch.copy(entries = kept)
|
||||
}
|
||||
|
||||
private suspend fun sendBatch(batch: LogBatch): Boolean =
|
||||
runCatching { sink.send(batch) }.getOrDefault(false)
|
||||
|
||||
/** Взять все накопленные записи в одну пачку (null — пусто). */
|
||||
private fun drainBuffer(): LogBatch? = lock.withLock {
|
||||
if (buffer.isEmpty()) return@withLock null
|
||||
val batch = LogBatch(device, buffer.toList())
|
||||
buffer.clear()
|
||||
batch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import pw.binom.viewmate.core.media.defaultHttpClient
|
||||
|
||||
/**
|
||||
* [LogSink] по HTTP: POST JSON-пачки на [url] (заголовок X-API-Key, если [apiKey] задан).
|
||||
* 2xx — принято (true); любой другой ответ/сетевая ошибка — false (пачка уйдёт в спул).
|
||||
*/
|
||||
class HttpLogSink(
|
||||
private val client: HttpClient,
|
||||
private val url: String,
|
||||
private val apiKey: String? = null,
|
||||
) : LogSink {
|
||||
override suspend fun send(batch: LogBatch): Boolean {
|
||||
val response: HttpResponse = runCatching {
|
||||
client.post(url) {
|
||||
if (apiKey != null) header("X-API-Key", apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(LogBatch.encodeBatch(batch))
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return response.status.isSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
/** Готовый HTTP-лог-синк со своим клиентом: вызывающая сторона не знает про Ktor. */
|
||||
fun httpLogSink(url: String, apiKey: String? = null): LogSink =
|
||||
HttpLogSink(defaultHttpClient(), url, apiKey)
|
||||
@@ -0,0 +1,44 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Одна лог-запись.
|
||||
* [ts] — часы устройства-источника, epoch-мс.
|
||||
* [tag] — подсистема (stt, hub, mc, …).
|
||||
* [source] — кто сгенерировал ("glasses" / "phone") — чтобы телефон мог пересылать
|
||||
* логи очков на сервер с сохранением источника.
|
||||
*/
|
||||
@Serializable
|
||||
data class LogEntry(
|
||||
val ts: Long,
|
||||
val tag: String,
|
||||
val message: String,
|
||||
val source: String = "unknown",
|
||||
)
|
||||
|
||||
/**
|
||||
* Пачка логов — единица передачи в «следующий» коллектор.
|
||||
* [device] — кто шлёт эту пачку (устройство-коллектор).
|
||||
*/
|
||||
@Serializable
|
||||
data class LogBatch(
|
||||
val device: String,
|
||||
val entries: List<LogEntry>,
|
||||
) {
|
||||
companion object {
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/** Пачка → JSON-строка (файл спула). */
|
||||
fun encodeBatch(batch: LogBatch): String = json.encodeToString(serializer(), batch)
|
||||
|
||||
/** JSON-строка → пачка; некорректный/битый текст — null. */
|
||||
fun decodeBatch(text: String): LogBatch? = runCatching {
|
||||
json.decodeFromString(serializer(), text)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/** Часы для лог-записей: у common-кода нет java.time — устройство-носитель подаёт свои. */
|
||||
fun interface LogClock {
|
||||
fun nowMillis(): Long
|
||||
}
|
||||
|
||||
/** Стенговые (wall-clock) часы — JVM-реализация (Android/JVM: `System.currentTimeMillis`). */
|
||||
expect class SystemClock() : LogClock
|
||||
|
||||
/** Зависанный (fixed) ход времени для юнит-тестов. */
|
||||
class FixedClock(private var now: Long = 0L) : LogClock {
|
||||
override fun nowMillis(): Long = now
|
||||
|
||||
/** Продвинуть «время» на [ms]. */
|
||||
fun advance(ms: Long) {
|
||||
now += ms
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/**
|
||||
* Лока (thread-safe) для буфера коллектора: KMP-common не даёт нативных лок-ов —
|
||||
* каждая платформа подставляет свою (Android/JVM: `java.util.concurrent.locks.ReentrantLock`).
|
||||
*/
|
||||
expect class LogLock() {
|
||||
/** Выполнить [action] под локом; вернуть результат. */
|
||||
fun <T> withLock(action: () -> T): T
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/**
|
||||
* Абстракция «следующего» получателя лог-пачек (сервер у телефона, сам телефон
|
||||
* у очков, чёрная дыра в тестах). Коллектор не знает про транспорт/протокол —
|
||||
* только про результат отправки.
|
||||
*/
|
||||
fun interface LogSink {
|
||||
/**
|
||||
* Отправить [batch] вниз по цепочке.
|
||||
* @return `true` — принято (пачка потреблена); `false` — не принято
|
||||
* (коллектор положит пачку в спул и попробует позже).
|
||||
*/
|
||||
suspend fun send(batch: LogBatch): Boolean
|
||||
}
|
||||
|
||||
/** LogSink, который пачки проглатывает (логирование отключено / нет цели). */
|
||||
object NoopLogSink : LogSink {
|
||||
override suspend fun send(batch: LogBatch): Boolean = true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
/**
|
||||
* Хранилище не отправленных пачек (спул): «не удалось послать → прихраним у себя
|
||||
* и позже попробуем ещё». FIFO: первым ретраится самая старая пачка.
|
||||
*
|
||||
* Доступ — из одного потока (таймер-корутина коллектора): peek/take на каждом
|
||||
* тике, write — при неудачной отправке.
|
||||
*/
|
||||
interface LogSpool {
|
||||
/** Добавить [batch] в конец очереди. */
|
||||
fun write(batch: LogBatch)
|
||||
|
||||
/** Самая старая пачка без удаления (null — пусто). */
|
||||
fun peek(): LogBatch?
|
||||
|
||||
/** Самая старая пачка с удалением (null — пусто). */
|
||||
fun take(): LogBatch?
|
||||
|
||||
/** Сколько пачек ждёт отправки. */
|
||||
fun pendingCount(): Int
|
||||
}
|
||||
|
||||
/**
|
||||
* In-память спул без персистентности (тесты, «прихранил в процессе»):
|
||||
* очередь с потолком [maxBatches] — при переполнении выбрасывается старейшая.
|
||||
*/
|
||||
class InMemoryLogSpool(private val maxBatches: Int = 32) : LogSpool {
|
||||
private val queue = ArrayDeque<LogBatch>()
|
||||
|
||||
override fun write(batch: LogBatch) {
|
||||
if (queue.size >= maxBatches) queue.removeFirst()
|
||||
queue.addLast(batch)
|
||||
}
|
||||
|
||||
override fun peek(): LogBatch? = queue.firstOrNull()
|
||||
|
||||
override fun take(): LogBatch? = queue.removeFirstOrNull()
|
||||
|
||||
override fun pendingCount(): Int = queue.size
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package pw.binom.viewmate.core.log
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import pw.binom.viewmate.core.media.defaultHttpClient
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Коллектор в Loki (Grafana): POST JSON в [LokiConfig.url] (Basic-auth).
|
||||
*
|
||||
* Важное: instance каждого stream'а = [LogEntry.source] записи ("phone"/"glasses"),
|
||||
* то есть в Loki сразу видно, откуда лог — с телефона или с очков. Пачка на
|
||||
* телефоне может содержать и то, и другое (телефон пересылает логи очков),
|
||||
* поэтому группируем по source и шлём несколько stream'ов одним запросом.
|
||||
*/
|
||||
class LokiLogSink(
|
||||
private val client: HttpClient,
|
||||
private val config: LokiConfig = LokiConfig(),
|
||||
) : LogSink {
|
||||
|
||||
private val authHeader = "Basic " + base64("${config.user}:${config.password}")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun send(batch: LogBatch): Boolean {
|
||||
if (batch.entries.isEmpty()) return true
|
||||
val payload = buildLokiPush(batch.entries, config.job)
|
||||
val response: HttpResponse = runCatching {
|
||||
client.post(config.url) {
|
||||
header("Authorization", authHeader)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(json.encodeToString(LokiPush.serializer(), payload))
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return response.status.isSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
/** Параметры Loki. Логин/пароль/адрес зашиты по умолчанию (см. запрос пользователя). */
|
||||
data class LokiConfig(
|
||||
val url: String = "https://loki.binom.pw/loki/api/v1/push",
|
||||
val user: String = "loki-ingest",
|
||||
val password: String = "q0OH8na60bE_KhGc6c1YOsUuFPrLjw2K",
|
||||
val job: String = "view-mate",
|
||||
)
|
||||
|
||||
/** Готовый Loki-синк со своим клиентом: вызывающая сторона не знает про Ktor/Basic-auth. */
|
||||
fun lokiLogSink(config: LokiConfig = LokiConfig()): LogSink =
|
||||
LokiLogSink(defaultHttpClient(), config)
|
||||
|
||||
@Serializable
|
||||
internal data class LokiStream(val stream: Map<String, String>, val values: List<List<String>>)
|
||||
|
||||
@Serializable
|
||||
internal data class LokiPush(val streams: List<LokiStream>)
|
||||
|
||||
internal fun buildLokiPush(entries: List<LogEntry>, job: String): LokiPush {
|
||||
val streams = entries
|
||||
.groupBy { it.source.ifBlank { "phone" } }
|
||||
.map { (source, es) ->
|
||||
val values = es
|
||||
.map { e -> listOf((e.ts * 1_000_000).toString(), "${e.tag}: ${e.message}") }
|
||||
.sortedBy { it[0] }
|
||||
LokiStream(stream = mapOf("job" to job, "instance" to source), values = values)
|
||||
}
|
||||
return LokiPush(streams = streams)
|
||||
}
|
||||
|
||||
private val B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
/** Минимальный base64 (только кодирование) — для Basic-auth, без новых зависимостей. */
|
||||
private fun base64(input: String): String {
|
||||
val bytes = input.encodeToByteArray()
|
||||
val out = StringBuilder()
|
||||
var i = 0
|
||||
while (i < bytes.size) {
|
||||
val b0 = bytes[i].toInt() and 0xFF
|
||||
val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xFF else 0
|
||||
val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xFF else 0
|
||||
val triple = (b0 shl 16) or (b1 shl 8) or b2
|
||||
out.append(B64[(triple ushr 18) and 0x3F])
|
||||
out.append(B64[(triple ushr 12) and 0x3F])
|
||||
out.append(if (i + 1 < bytes.size) B64[(triple ushr 6) and 0x3F] else '=')
|
||||
out.append(if (i + 2 < bytes.size) B64[triple and 0x3F] else '=')
|
||||
i += 3
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
@@ -42,6 +42,35 @@ fun defaultHttpClient(): HttpClient = HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-клиент для media-mirror: тяжёлые опросы (mirror.list с большим лимитом)
|
||||
* на сервере считаются заметно дольше, чем обычные запросы, поэтому таймауты
|
||||
* шире [defaultHttpClient] — иначе живой, но медленный ответ упирается в таймаут.
|
||||
*/
|
||||
fun defaultMirrorHttpClient(): HttpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
explicitNulls = false
|
||||
}
|
||||
)
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
connectTimeoutMillis = 15_000
|
||||
requestTimeoutMillis = 90_000
|
||||
}
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
|
||||
}
|
||||
}
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
/** Флаг debug-логирования HTTP (по умолчанию включён). */
|
||||
object HttpClientDebugLogging {
|
||||
var enabled: Boolean = true
|
||||
|
||||
@@ -2,6 +2,8 @@ package pw.binom.viewmate.core.media
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
@@ -19,7 +21,7 @@ import io.ktor.http.contentType
|
||||
class MirrorClient(
|
||||
private val baseUrl: String, // https://mirror.binom.pw
|
||||
private val apiKey: String, // X-API-Key
|
||||
private val http: HttpClient = defaultHttpClient(),
|
||||
private val http: HttpClient = defaultMirrorHttpClient(),
|
||||
) {
|
||||
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
|
||||
suspend fun createMirror(itemId: String): MirrorJob {
|
||||
@@ -42,15 +44,26 @@ class MirrorClient(
|
||||
return response.body()
|
||||
}
|
||||
|
||||
/** Список зеркал: статус (null — все) + лимит. */
|
||||
/** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
|
||||
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
|
||||
val response = http.get("$baseUrl/api/mirror") {
|
||||
header("X-API-Key", apiKey)
|
||||
parameter("limit", limit)
|
||||
if (status != null) parameter("status", status)
|
||||
var last: Exception? = null
|
||||
repeat(LIST_ATTEMPTS) { attempt ->
|
||||
try {
|
||||
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")
|
||||
return response.body()
|
||||
throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
|
||||
}
|
||||
|
||||
/** Удаление зеркала (204). */
|
||||
@@ -69,4 +82,11 @@ class MirrorClient(
|
||||
response.ensureSuccess("mirror.scan")
|
||||
return response.body()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
|
||||
private const val LIST_ATTEMPTS = 2
|
||||
|
||||
private const val LIST_RETRY_DELAY_MS = 1_500L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +128,9 @@ class PhoneActions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать контент в память очков: DownloadFiles с presigned URL всех файлов
|
||||
* (video.mkv + audio-N.ogg) и известными размерами дорожек [sizes] (key → байт).
|
||||
* Скачать контент в память очков: DownloadFiles с presigned URL только видео (video.mkv).
|
||||
* Звук на очки не идёт — аудио-дорожки скачиваются только на телефон.
|
||||
* [sizes] — известные размеры дорожек (key → байт).
|
||||
*/
|
||||
suspend fun downloadToGlasses(
|
||||
itemId: String,
|
||||
|
||||
@@ -86,6 +86,14 @@ data class SetScale(val scale: Float) : HostToGlasses
|
||||
@SerialName("set_vertical")
|
||||
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 — весь накопленный текст сессии. */
|
||||
@Serializable
|
||||
@SerialName("stt_phrase")
|
||||
|
||||
@@ -3,6 +3,8 @@ package pw.binom.viewmate.core.protocol
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
import pw.binom.viewmate.core.log.LogEntry
|
||||
|
||||
/**
|
||||
* Сообщения от очков к хосту (телефону).
|
||||
* Полиморфная сериализация: дискриминатор "type" со значением @SerialName.
|
||||
@@ -70,6 +72,10 @@ data class PlaybackPosition(
|
||||
val playing: Boolean,
|
||||
val itemId: String? = null,
|
||||
val audioIndex: Int = 0,
|
||||
/** Время отправки на часах очков (epoch ms). Телефон мерит латентность линка = now − sentTs. 0 = не замерено. */
|
||||
val sentTs: Long = 0L,
|
||||
/** Длительность видео на очках, мс; -1 = неизвестно (стара прошивка / ещё не prepared). */
|
||||
val durationMs: Long = -1L,
|
||||
) : GlassesToHost
|
||||
|
||||
/** Очки выключились / экран погас. */
|
||||
@@ -102,3 +108,22 @@ data class SttAudio(val data: ByteArray) : GlassesToHost {
|
||||
@Serializable
|
||||
@SerialName("stop_stt")
|
||||
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,
|
||||
itemId = "movie-1",
|
||||
audioIndex = 2,
|
||||
durationMs = 7_200_000,
|
||||
)
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
assertTrue("\"itemId\":\"movie-1\"" in text)
|
||||
@@ -135,6 +136,7 @@ class GlassesToHostTest {
|
||||
assertTrue(decoded.playing)
|
||||
assertEquals("movie-1", decoded.itemId)
|
||||
assertEquals(2, decoded.audioIndex)
|
||||
assertEquals(7_200_000, decoded.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -147,6 +149,7 @@ class GlassesToHostTest {
|
||||
assertTrue(!decoded.playing)
|
||||
assertNull(decoded.itemId)
|
||||
assertEquals(0, decoded.audioIndex)
|
||||
assertEquals(-1L, decoded.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -210,4 +213,14 @@ class GlassesToHostTest {
|
||||
assertIs<StopStt>(decoded)
|
||||
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("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