Compare commits

11 Commits

Author SHA1 Message Date
subochev e81026b9f5 Слои записи и оверлей берут стт-статус и сессию из единого GlassesStt 2026-09-07 04:27:05 +03:00
subochev 7db0fc71de Слои записи и оверлей берут стт-статус и сессию из единого GlassesStt 2026-09-07 03:59:15 +03:00
subochev 8083978849 Слой записи владеет микрофоном и распознаванием, движок — только диалогом 2026-09-07 03:27:31 +03:00
subochev 1b817aa9a6 Сохраняем прогресс просмотра и возобновляем с позиции 2026-09-07 01:09:44 +03:00
subochev 79a500150d Пейринг RayNeo: кнопка ручного (без QR) ре-паринга + debug-роуты + keep-screen-on
- MercuryBridge.pairClassicAuto: классический discovery → найти RayNeo по
  имени → createBond (без QR и без камеры очков) для случая «бонд слетел,
  очки в first-pairing». discoverGlasses — список найденных.
- MercuryPanel: кнопка «🔗 паринг (вручную)» рядом с QR-парингом
- DebugHttpServer: /mercury/pair, /mercury/pair/stop, /mercury/pair/classic,
  /mercury/unpair, /mercury/bonded, /mercury/discover, поля pairing в /status
- keep-screen-on, чтобы QR/состояние реально были на экране телефона
2026-09-04 03:08:38 +03:00
subochev f68ea10342 Сборка release: подпись ключом Binom (v2+v3) + таск assembleSigned
signingConfigs в app-phone/app-glasses берут кестор и креды из скилла
android-sign (вне git); без кестора release собирается без подписи.
assembleSigned — собрать оба приложения одним шагом.
2026-09-04 03:08:28 +03:00
subochev 5c86ed00b4 STT: очки распознают речь локально и шлют готовый текст (SttText) в телефон
- glasses: локальный ASR через общий pw.binom.asr (GlassesAsr/GlassesSttSession/SttModelProvider)
- protocol: SttText — очки присылают распознанный текст в телефон; телефон кидает его в LLM
  (старый SttAudio/StopStt оставлен фолбэком для старых очей)
- phone: SttStreamer делегирует общему ASR; GlassesServer роутит SttText в LLM
- lib-core: AudioPrep общий
2026-09-04 03:08:26 +03:00
subochev 0b3f9ff975 Mirror: медленный list(500) не роняет каталог и не дёргается в цикле
- отдельный defaultMirrorHttpClient (connect 15с / request 90с) + 1 повтор на list():
  тяжёлый GET /api/mirror?limit=N на сервере дольше общего 30с-таймаута и подёргивается (503);
- каталог: зеркала грузятся в отдельном try — сбой/таймаут не чистит уже загруженный каталог,
  просто временно без бейджей;
- детали: убрали тяжёлый list(limit=500) каждые 3 с (progress зеркала на сервере не живой, 0
  до конца) — в цикле опрашивается только дешёвый mirrorByItem.
2026-09-02 23:02:56 +03:00
subochev 40723fa584 Каталог: массовые действия (конвертировать/скачать/удалить), фильтр «только скачанные» и BackHandler вложенных экранов 2026-09-02 22:38:54 +03:00
subochev 728116662e Добавляем WatchProgressStore (watchprogress.json) и его тесты 2026-09-02 22:38:30 +03:00
subochev 0cfd92a68f Добавляем экран «Активность»: что сейчас конвертируется и скачивается 2026-09-02 22:37:31 +03:00
51 changed files with 3448 additions and 673 deletions
+42
View File
@@ -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 =
@@ -0,0 +1,182 @@
package pw.binom.viewmate.glasses
import android.view.MotionEvent
import androidx.compose.runtime.Composable
import com.rayneo.arsdk.android.touch.FlingArgs
import com.rayneo.arsdk.android.touch.TempleAction
/**
* Дискретный жест трекпада. Плавного (continuous) жеста нет: [TempleAction.SlideContinuous]
* по знаку дельты сворачивается в дискретный свайп ([toGesture]) — плавной прокрутки диалога
* ассистента больше не нужно.
*/
enum class Gesture { Click, DoubleClick, TripleClick, SwipeForward, SwipeBackward }
/**
* SDK-событие трекпада → дискретный [Gesture]. Возвращает null, если жест игнорируется
* (MoveUp/ActionUp/Idle/LongClick). [TempleAction.SlideContinuous] → дискретный свайп по знаку
* дельты (>=0 вперёд).
*/
fun TempleAction.toGesture(): Gesture? = when (this) {
is TempleAction.Click -> Gesture.Click
is TempleAction.DoubleClick -> Gesture.DoubleClick
is TempleAction.TripleClick -> Gesture.TripleClick
is TempleAction.SlideForward -> Gesture.SwipeForward
is TempleAction.SlideBackward -> Gesture.SwipeBackward
is TempleAction.SlideContinuous -> if (this.delta >= 0f) Gesture.SwipeForward else Gesture.SwipeBackward
else -> null
}
/**
* Обратный [toGesture] — [Gesture] → SDK-событие, чтобы маршрутизацию layout-а прогнать через
* единственную матрицу [pw.binom.viewmate.glasses.gestures.gestureDecision] (она ждёт
* [TempleAction]). Плавного жеста нет — дискретные свайпы.
*/
fun Gesture.toAction(): TempleAction = when (this) {
Gesture.Click -> TempleAction.Click()
Gesture.DoubleClick -> TempleAction.DoubleClick()
Gesture.TripleClick -> TempleAction.TripleClick()
Gesture.SwipeForward -> TempleAction.SlideForward(DUMMY_FLING)
Gesture.SwipeBackward -> TempleAction.SlideBackward(DUMMY_FLING)
}
/**
* Плейсхолдер [FlingArgs] для свайпов в [toAction]: матрица [gestureDecision] смотрит только
* тип жеста, args свайпа ей не нужны, поэтому одна переиспользуемая заглушка (один
* [MotionEvent] на всё время — pool-утечки нет).
*/
private val DUMMY_FLING: FlingArgs = run {
val e = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0)
FlingArgs(e, e, 0f, 0f)
}
/**
* Экран/слой очков. Рисуется композитом в стек [LayoutManager]: снизу вверх, между слоями
* затемнение. [gesture] вызывается ТОЛЬКО вершиной стека — [LayoutManager.dispatch] маршрутизирует
* каждый жест в [Layout.gesture] активного (верхнего) layout-а, остальные слои жест не получают.
*/
interface Layout {
/** Основной экран слоя (видео/чат и т.п.). */
@Composable
fun drawStage()
/** Панель состояния: вызывается первой, потом стандартные время/заряд. */
@Composable
fun drawStatusPanel()
/** Обработка жестов данным слоем. Вызывается менеджером только для верхнего слоя. */
fun gesture(g: Gesture)
/**
* Слой открыт (встал вершиной стека) — менеджер вызывает сразу после [LayoutContext.open].
* Под-слой использует для своего «старта»: напр. [pw.binom.viewmate.glasses.ui.layers.ListeningLayout]
* здесь открывает микрофон. Корневой слой не получает (он не «открывается», а создаётся менеджером).
*/
fun onEnter() {}
/** Слой закрыт (POP-нут из стека) — менеджер вызывает в [LayoutContext.close]. Симметрично [onEnter]. */
fun onExit() {}
}
/**
* Контекст, доступный layout-у: открыть под-слой ([open]), закрыть себя ([close]), управлять
* глобальным индикатором записи ([setRecordingIndicator]). Индикатор горит красным, пока активен
* хотя бы один слой, включивший его; выключается, когда этот слой перестал быть активным (POP)
* или переключил его в false. Экземпляр создаёт [LayoutManager] — каждый layout получает один
* общий контекст (самого менеджера).
*/
interface LayoutContext {
fun setRecordingIndicator(on: Boolean)
/** Закрыть текущий (верхний) слой. Корневой слой закрыться не может. */
fun close()
/** Выйти до корня (корневой слой остаётся). */
fun closeToRoot()
/**
* Открыть новый слой поверх: менеджер сам создаст layout через [factory] и передаст ему
* контекст. Инвариант — только активный (верхний) слой открывает под-слой.
*/
fun open(factory: (LayoutContext) -> Layout)
}
/**
* ЛIFO-стек layout-ов очков (то, что пользователь называет «стек»): корень (двойным кликом не
* вытолкнуть) → сверху под-слои. Владеет стеком и контекстом одновременно — один объект, без
* «расщеплённого» context/manager. Корень создаётся [rootFactory], которому менеджер отдаёт сам
* себя как [LayoutContext] (корень открывает под-слои через [LayoutContext.open]).
*
* Композитинг — [compositing]: рисуются ВСЕ слои снизу вверх, между ними затемнение
* (например `фильм + затемнение + ассистент`). Жесты — [dispatch] в вершину.
*/
class LayoutManager(
rootFactory: (LayoutContext) -> Layout,
) : LayoutContext {
private val stack = ArrayDeque<Layout>()
private val recordingOn = linkedSetOf<Layout>()
/** Колбэк на каждую mутацию стека (open/close/closeToRoot) — для внешних проекций (напр. navStack). */
var onChange: (List<Layout>) -> Unit = {}
init {
stack.addLast(rootFactory(this))
onChange(stack.toList())
}
/** Верхний (активный) слой. */
val current: Layout get() = stack.last()
/** Корневой слой (не выталкивается; владелец медиастейта, напр. [MoveLayout]). */
val root: Layout get() = stack.first()
/** Глубина стека (1 = только корень). */
val depth: Int get() = stack.size
/** Стек снизу вверх. */
fun layersBottomUp(): List<Layout> = stack.toList()
/** Композит: каждый слой + флаг затемнения (затемнять каждый слой, кроме верхнего). */
data class Layer(val layout: Layout, val dim: Boolean)
/** Композит снизу вверх: film → dim → assistant. Верхний (последний) — без затемнения. */
fun compositing(): List<Layer> =
stack.mapIndexed { i, l -> Layer(l, i < stack.lastIndex) }
/** Маршрутизация жеста в верхний слой — единственная точка входа. */
fun dispatch(g: Gesture) {
current.gesture(g)
}
// LayoutContext:
override fun open(factory: (LayoutContext) -> Layout) {
val child = factory(this)
stack.addLast(child)
child.onEnter()
onChange(stack.toList())
}
override fun close() {
// Корневой слой не закрывается.
if (stack.size > 1) {
val popped = stack.removeLast()
popped.onExit()
recordingOn.remove(popped)
onChange(stack.toList())
}
}
/** Выйти до корня (корень остаётся). */
override fun closeToRoot() {
while (stack.size > 1) close()
}
override fun setRecordingIndicator(on: Boolean) {
val top = current
if (on) recordingOn.add(top) else recordingOn.remove(top)
}
/** Индикатор записи: горит, пока активен хотя бы один слой с включённым индикатором. */
fun recordingIndicator(): Boolean = recordingOn.isNotEmpty()
}
@@ -11,6 +11,11 @@ import kotlinx.coroutines.CoroutineScope
import pw.binom.viewmate.core.log.BatchingLogCollector
import pw.binom.viewmate.core.log.FileLogSpool
import pw.binom.viewmate.core.log.SystemClock
import pw.binom.viewmate.glasses.gestures.NavFrame
import pw.binom.viewmate.glasses.stt.GlassesStt
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
import pw.binom.viewmate.glasses.ui.layers.toFrame
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
data class ChatEntry(val role: String, val text: String)
@@ -44,13 +49,35 @@ class GlassesApp : Application() {
lateinit var hostConnection: HostConnection
private set
lateinit var movieController: MovieController
private set
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
lateinit var downloadManager: GlassesDownloadManager
private set
/** Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте. */
lateinit var stt: GlassesStt
private set
/**
* Персистентный менеджер стека layout-ов (корень [MoveLayout] получает [LayoutContext] и
* контекст приложения для своего медиастейта). Единственный владелец стека: слои
* открывают/закрывают кадры через [LayoutContext.open]/[LayoutContext.close];
* [navStack] — проекция этого стека для оверлея.
*/
val layoutManager = LayoutManager { ctx -> MoveLayout(ctx, this) }
/** Корневой слой (владелец медиастейта): протокол/lifecycle/эффекты дотягиваются до
* [MovieController] через него ([rootLayer.controller]). */
val rootLayer: MoveLayout get() = layoutManager.root as MoveLayout
/**
* Движок ассистента: внятный стейт диалога (фазы/тексты/история) + его жестовые эффекты.
* Владелец стейта, живёт всё время приложения; слои [AssistantLayout]/[ListeningLayout]
* и оверлей дотягиваются до него, протокол/lifecycle пишут через [assistantEngine].
* Зависимости от [GlassesApp] (stt/hostConnection/rootLayer) — лениво, в методах.
*/
val assistantEngine = AssistantEngine(this)
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
lateinit var logCollector: BatchingLogCollector
private set
@@ -68,39 +95,20 @@ class GlassesApp : Application() {
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f)
/**
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click,
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
*/
val listening = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
val sttText = MutableStateFlow("")
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
// Стейт диалога ассистента (thinking/assistantText/chatHistory/answerScrollDelta + idle-hint
// sttNoSpeech) — в [assistantEngine], владелец стейта ассистента. Микрофон и распознавание
// (sttText, старт/стоп стт-сессии) владеет слой [ListeningLayout]; «модель не скачалась» —
// из stt.status. Движок микрофоном не владеет.
/**
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
* Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не
* вытолкнуть) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон, чат на фоне).
* Тройной клик в кино кладёт «ассистент» + «слушаю»; двойной клик POP-ит верхний кадр:
* из «слушаю» → в кадр ассистента (idle, микрофон закрыт, чат не прерван), из idle/
* «думаю»/«ответ» → в «фильм». Держится до явного выхода (двойной клик в кадре
* ассистента, 30с тишины, потеря фокуса) и сбрасывается в [AssistantEngine.exitMode].
*/
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/**
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
*/
val thinking = MutableStateFlow(false)
/**
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
*/
val answerScrollDelta = MutableStateFlow(0f)
val navStack = MutableStateFlow(listOf(NavFrame.Film))
/**
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
@@ -132,12 +140,12 @@ class GlassesApp : Application() {
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
MercurySDK.init(this)
movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
// Медиадвижок живёт в корневом слое (rootLayer.controller), не в приложении.
rootLayer.controller.eventLogger = { msg -> logToFile("mc: $msg") }
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
hostConnection = HostConnection(
fallbackUrl = GlassesConfig.HOST_URL,
movieController = movieController,
movieController = rootLayer.controller,
downloadManager = downloadManager,
context = applicationContext,
)
@@ -157,6 +165,19 @@ class GlassesApp : Application() {
attachLogCollector(logCollector)
pw.binom.viewmate.core.logCollector = logCollector
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
// Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте.
// Модель если отсутствует докачивается фоновой корутиной стта (idempotent, по Range). Куда
// уходит готовый текст (onResult) регистрирует владелец — слой записи (ListeningLayout.onEnter);
// приложение результат к mainActivity не шьёт.
stt = GlassesStt(filesDir, bgScope)
stt.ensure()
// Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager,
// navStack — лишь его зеркало. Начальная проекция (корень = Film).
layoutManager.onChange = { layouts -> navStack.value = layouts.map { it.toFrame() } }
navStack.value = layoutManager.layersBottomUp().map { it.toFrame() }
log("app", "layout-менеджер: корень=${layoutManager.current.toFrame()}, navStack=[${navStack.value}]")
}
companion object {
@@ -40,9 +40,6 @@ import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.SetVertical
import pw.binom.viewmate.core.protocol.Shutdown
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
@@ -354,56 +351,18 @@ class HostConnection(
is ShowText -> {
addMessage("ShowText: ${msg.text}")
GlassesApp.instance.thinking.value = false
GlassesApp.instance.answerScrollDelta.value = 0f
GlassesApp.instance.assistantText.value = msg.text
GlassesApp.instance.assistantEngine.onShowText(msg.text)
}
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is ChatHistoryMsg -> {
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
GlassesApp.instance.chatHistory.value = msg.messages
GlassesApp.instance.assistantEngine.onChatHistory(
msg.messages
.filter { it.role == "user" || it.role == "assistant" }
.map { ChatEntry(it.role, it.content) }
}
is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full
log("stt", "фраза: ${msg.phrase}")
}
is SttDone -> {
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
GlassesApp.instance.listening.value = false
}
is SttCancel -> {
when (msg.reason) {
SttCancel.REASON_CANCEL -> {
// Явная отмена диктовки двойным тапом: очки САМИ уже вернулись
// в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат,
// оверлей остаётся, микрофон готов к следующей записи). Это всего
// подтверждение, что телефон отменил STT-сессию (stt.reset()) —
// состояния (listening/thinking/оверлей) НЕ трогаем.
log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте")
}
SttCancel.REASON_TIMEOUT -> {
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
log("stt", "тишина 30с — выход из режима общения")
GlassesApp.instance.mainActivity?.onSttTimeout()
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
}
else -> {
// reason="user" — пустой клик без голоса: не выходим из режима,
// возвращаемся в «слушаю» (микрофон снова открыт).
log("stt", "отменено — возврат в «слушаю»")
GlassesApp.instance.thinking.value = false
GlassesApp.instance.mainActivity?.resumeSttListening()
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
}
}
.map { ChatEntry(it.role, it.content) },
)
}
is Shutdown -> {
@@ -413,6 +372,9 @@ class HostConnection(
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
android.os.Process.killProcess(android.os.Process.myPid())
}
// Неподдерживаемое host-сообщение — молча игнор (в т.ч. legacy phone-STT: очки не ждут).
else -> Unit
}
}
@@ -48,20 +48,14 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.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
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.LayoutStackContent
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX
@@ -70,11 +64,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`
@@ -96,10 +85,10 @@ class MainActivity : BaseEventActivity() {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
log("ui", "SCREEN_OFF receiver")
val was = GlassesApp.instance.movieController.pauseIfPlaying()
val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("SCREEN_OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock()
GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас (receiver) — пауза")
}
@@ -108,7 +97,7 @@ class MainActivity : BaseEventActivity() {
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) {
resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume()
GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries()
log("ui", "экран включён (receiver) — продолжаю")
}
@@ -126,24 +115,10 @@ class MainActivity : BaseEventActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
val controller = GlassesApp.instance.movieController
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) {
GlassesMovieScreen(player = player, title = title)
} else {
ConnectionScreen()
}
// Оверлей режима общения: поверх любого экрана (затемняет кино),
// виден в «слушаю»/«думаю»/«ответ виден».
AssistantOverlay()
}
// Layout-стек (LIFO): персистентный менеджер (GlassesApp.layoutManager) композитит
// все слои снизу вверх (кино → затемнение → чат → слушаю). Триггер рекомпозиции —
// стек менеджера (зеркало navStack, обновляется через ctx.open/close).
LayoutStackContent(manager = GlassesApp.instance.layoutManager)
}
}
hideSystemBars()
@@ -179,16 +154,16 @@ class MainActivity : BaseEventActivity() {
val state = dm.getDisplay(displayId).state
logEvent("display state=$state")
if (state == Display.STATE_OFF) {
val was = GlassesApp.instance.movieController.pauseIfPlaying()
val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("display OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock()
GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
} else if (state == Display.STATE_ON) {
logEvent("display ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) {
resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume()
GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries()
}
}
@@ -219,148 +194,23 @@ class MainActivity : BaseEventActivity() {
/**
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
* (debugReceiver, эмулятор). Жест деспачится в верхний слой стека через персистентный
* [pw.binom.viewmate.glasses.LayoutManager] — каждый слой сам решает, что со своим жестом
* делать (MoveLayout / AssistantLayout / ListeningLayout).
*/
private fun onTempleAction(action: TempleAction) {
val app = GlassesApp.instance
val state = AssistantState(
listening = app.listening.value,
thinking = app.thinking.value,
sttText = app.sttText.value,
assistantText = app.assistantText.value,
)
when (val decision = gestureDecision(action, state)) {
GestureDecision.Ignore ->
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
GestureDecision.CallAssistant -> startAssistantMode()
GestureDecision.TogglePlayback -> togglePlayback()
GestureDecision.SendPhrase -> sendPhrase()
GestureDecision.NewPhrase -> startNewPhrase()
GestureDecision.CancelDictation -> cancelDictation()
GestureDecision.ExitMode -> exitAssistantMode()
is GestureDecision.Seek -> seekBy(decision)
is GestureDecision.ScrollAnswer -> {
val acc = app.answerScrollDelta.value + decision.delta
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
app.answerScrollDelta.value = acc
}
}
}
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
private fun startAssistantMode() {
val app = GlassesApp.instance
if (app.listening.value) {
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
val g = action.toGesture() ?: run {
log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор")
return
}
log("stt", "ассистент: слушаю")
if (app.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
app.listening.value = true
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
sttMic.start(filesDir) { app.listening.value }
log("gesture", "жест: ${g}")
GlassesApp.instance.layoutManager.dispatch(g)
}
/** Click (кино): плей/пауза — как сегодня. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
private fun sendPhrase() {
log("gesture", "Click → отправить фразу")
sttMic.stop()
GlassesApp.instance.listening.value = false
GlassesApp.instance.thinking.value = true
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
}
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
private fun startNewPhrase() {
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
val app = GlassesApp.instance
app.assistantText.value = ""
app.sttText.value = ""
app.thinking.value = false
app.answerScrollDelta.value = 0f
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
}
/**
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
*/
fun resumeSttListening() {
val app = GlassesApp.instance
app.thinking.value = false
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
log("stt", "возврат в «слушаю» (SttCancel)")
}
/**
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на
* экране (даже если истории диалога ещё нет — отмена не закрывает чат).
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]).
*/
private fun cancelDictation() {
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
val app = GlassesApp.instance
sttMic.stop()
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))
}
}
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
/**
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
*/
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
private fun finishAssistantMode(reason: String) {
log("gesture", reason)
sttMic.stop()
val app = GlassesApp.instance
app.listening.value = false
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
}
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
private fun seekBy(seek: GestureDecision.Seek) {
log("gesture", "жест: свайп → ${seek.command}")
GlassesApp.instance.movieController.applyMediaCommand(
MediaCommandMsg(seek.command, seek.seekMs)
) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
// Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]:
// слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout.
// Activity о распознавании не знает: запись самозамкнута в [ListeningLayout] (локальная
// стт), host-STT-события очки не обрабатывают (молча игнор в HostConnection).
/**
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
@@ -389,15 +239,15 @@ class MainActivity : BaseEventActivity() {
log("ui", "onPause")
val app = GlassesApp.instance
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
// в лаунчер) режим общения гарантированно закрывается — стек POP-ится до фильма.
if (app.navStack.value.size > 1) {
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
app.assistantEngine.exitMode("onPause — потеря фокуса, выход из режима общения")
}
val was = app.movieController.pauseIfPlaying()
val was = app.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("onPause: pause wasPlaying=$was")
app.movieController.ensureWifiLock()
app.rootLayer.controller.ensureWifiLock()
app.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас — пауза")
}
@@ -409,7 +259,7 @@ class MainActivity : BaseEventActivity() {
logEvent("onResume: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) {
resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume()
GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries()
log("ui", "экран включён — продолжаю")
}
@@ -2,39 +2,66 @@ package pw.binom.viewmate.glasses.gestures
import com.rayneo.arsdk.android.touch.TempleAction
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.glasses.stt.SttModelStatus
/**
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
*
* @NONE — кино: ассистент не вызван.
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
* @ANSWER — ответ виден: listening=false и assistantText непуст.
* Навигация — ЛIFO-стек [NavFrame]: корень [NavFrame.Film] (двойным кликом не вытолкнуть),
* сверху [NavFrame.Assistant] (чат), сверху [NavFrame.Listening] (микрофон, чат на фоне).
* Двойной клик — POP верхнего кадра. Режим выводится из вершины стека + контента кадра
* ассистента:
*
* @NONE — кино (вершина Film): ассистент не вызван.
* @LISTENING — вершина Listening: микрофон открыт, чат виден на фоне.
* @THINKING — вершина Assistant, фраза отправлена, ждём ответ (ShowText).
* @ANSWER — вершина Assistant, ответ виден (assistantText непуст).
* @IDLE — вершина Assistant без «думаю»/ответа: микрофон закрыт, чат на месте.
*/
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, IDLE, }
/** Кадр стека навигации очков. Снизу вверх: [Film] (корень) → [Assistant] → [Listening]. */
enum class NavFrame { Film, Assistant, Listening }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState(
/** Микрофон открыт (TripleClick → слушаю). */
val listening: Boolean = false,
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
/** Стек навигации, снизу вверх. Внизу всегда [NavFrame.Film] (корень). */
val stack: List<NavFrame> = listOf(NavFrame.Film),
/** «Думаю»: фраза отправлена, ждём ответ (ShowText). Контент кадра ассистента. */
val thinking: Boolean = false,
/** Накопленные фразы (SttPhrase, full). */
/** Живой текст записи (локальное распознавание в [ListeningLayout]). Контент кадра «слушаю». */
val sttText: String = "",
/** Ответ ассистента (ShowText). */
/** Ответ ассистента (ShowText). Контент кадра ассистента. */
val assistantText: String = "",
)
/** Попытка начать диктовку отклонена: модель не скачалась (микрофон не открыт). */
val sttStartBlocked: Boolean = false,
/** Последний стоп/отправка не поймала голоса — показать «не расслышал». */
val sttNoSpeech: Boolean = false,
) {
/** Вершина стека — текущий кадр. */
val top: NavFrame get() = stack.last()
/** Микрофон открыт: вершина — [NavFrame.Listening]. */
val listening: Boolean get() = top == NavFrame.Listening
/** В кадре ассистента (idle/думаю/ответ/слушаю): вершина Assistant или Listening. */
val inAssistant: Boolean get() = top == NavFrame.Assistant || top == NavFrame.Listening
}
/**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
* он приоритетнее «думаю»; вершина [NavFrame.Listening] всегда побеждает (микрофон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*
* Стек: фильм (корень) → ассистент → слушаю. «Ассистент» без «слушаю»/ответа/«думаю»
* (вершина [NavFrame.Assistant]) — idle-кадр [IDLE]: микрофон закрыт, чат не прерван.
*/
fun assistantMode(state: AssistantState): AssistantMode = when {
state.listening -> AssistantMode.LISTENING
state.top == NavFrame.Listening -> AssistantMode.LISTENING
state.top == NavFrame.Assistant -> when {
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
else -> AssistantMode.NONE
}
@@ -44,8 +71,9 @@ fun assistantMode(state: AssistantState): AssistantMode = when {
*
* | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------|
* | Кино | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | отмена | игнор | игнор |
* | Кино (корень) | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | стоп → idle | игнор | игнор |
* | Ассистент (idle) | открыть запись | выход | запись | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/
sealed interface GestureDecision {
@@ -65,14 +93,17 @@ sealed interface GestureDecision {
data object NewPhrase : GestureDecision
/**
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
* оверлей остаётся на экране даже при пустой истории диалога.
* Сторона очков: StopStt(cancel=true) на телефон.
* Двойной в «слушаю» (стоп, POP верхнего кадра): закрыть микрофон, ОСТАТЬСЯ в ассистенте
* (стек: фильм → ассистент, без «слушаю» сверху) — см. [AssistantState.stopListeningFrame].
* Выхода нет: клик открывает запись снова, двойной — выход из чата. Сторона очков:
* StopStt(cancel=true).
*/
data object CancelDictation : GestureDecision
data object StopListening : GestureDecision
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
/** Клик/тройной в idle-кадре ассистента: снова открыть микрофон (вниз по стеку — «слушаю»). */
data object BeginListening : GestureDecision
/** Двойной в «думаю»/«ответ виден»/idle: выход из режима — закрыть оверлей, фильм на паузе. */
data object ExitMode : GestureDecision
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
@@ -86,15 +117,11 @@ sealed interface GestureDecision {
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
*
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
* меняем знак здесь, в одной точке).
*/
fun gestureDecision(
action: TempleAction,
state: AssistantState,
seekStepMs: Long = 30_000,
pageDeltaPx: Float = 200f,
): GestureDecision = when (val mode = assistantMode(state)) {
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
AssistantMode.NONE -> when (action) {
@@ -105,9 +132,30 @@ fun gestureDecision(
?: GestureDecision.Ignore
}
AssistantMode.LISTENING -> when (action) {
// «Слушаю» (вершина — Listening). Модель не скачалась (стtStartBlocked): микрофон не открыт,
// кадр на месте — Click = повторить попытку, DoubleClick = выход. Иначе: Click — отправить,
// DoubleClick — стоп (POP «слушаю» → idle).
AssistantMode.LISTENING -> when {
state.sttStartBlocked -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
else -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
// Стоп: POP «слушаю» → кадр ассистента (idle), чат не прерван.
is TempleAction.DoubleClick -> GestureDecision.StopListening
else -> GestureDecision.Ignore
}
}
// Idle-кадр ассистента (стек: фильм → ассистент, без «слушаю»): микрофон закрыт.
AssistantMode.IDLE -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.TripleClick -> GestureDecision.BeginListening
// Выход из чата: POP «ассистент» → корень (кино).
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
@@ -118,47 +166,84 @@ fun gestureDecision(
}
AssistantMode.ANSWER -> when (action) {
// Плавной и дискретной прокрутки ответа больше нет (решение пользователя): свайпы
// игнорируем — из-под ответа не ищем по (приостановленному) фильму и не скроллим текст.
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
is TempleAction.SlideContinuous ->
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
// два строка выше.
GestureDecision.ScrollAnswer(action.delta)
else -> GestureDecision.Ignore
}
}
/**
* Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»):
* возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый
* текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся
* на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true,
* т.к. listening=true). Закрытие ассистента — отдельное явное действие:
* двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]).
* Чистая функция — unit-тесты без Android.
* Тройной клик в фильме: на стек кладём кадр ассистента (чат) и сразу кадр «слушаю»
* (микрофон) — стек [Film, Assistant, Listening]. Чат виден на фоне. Чистая функция.
*/
fun afterCancelDictation(state: AssistantState): AssistantState =
state.copy(listening = true, thinking = false, sttText = "")
fun AssistantState.enterAssistant(): AssistantState =
copy(
stack = stack + NavFrame.Assistant + NavFrame.Listening,
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
/** Клик в idle/«думаю»/«ответ»: кладём кадр «слушаю» поверх кадра ассистента. Чистая функция. */
fun AssistantState.beginListeningFrame(): AssistantState =
copy(stack = stack + NavFrame.Listening, thinking = false, sttText = "", sttNoSpeech = false)
/**
* Стоп-записи ([GestureDecision.StopListening], двойной в «слушаю» / пустой клик): POP кадра
* «слушаю» — остаёмся в кадре ассистента (idle), чат НЕ прерван, микрофон закрыт. [noSpeech] —
* показать «не расслышал». Возобновить запись — Click/Triple ([GestureDecision.BeginListening]);
* выйти — DoubleClick ([GestureDecision.ExitMode]). Чистая функция — unit-тесты без Android.
*/
fun AssistantState.stopListeningFrame(noSpeech: Boolean): AssistantState =
copy(stack = stack.dropLast(1), thinking = false, sttText = "", sttNoSpeech = noSpeech)
/**
* Выход из чата ([GestureDecision.ExitMode], двойной в кадре ассистента / таймаут тишины):
* POP кадра ассистента до корня [NavFrame.Film] (фильм). Сброс контента. Чистая функция.
*/
fun AssistantState.exitToFilm(): AssistantState =
copy(
stack = listOf(NavFrame.Film),
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Оверлей общения виден, пока в кадре ассистента (idle/думаю/ответ/слушаю) или блок модели. */
fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank()
state.inAssistant || state.sttStartBlocked
/**
* Заголовок оверлея (над ответом). null — только ответ.
* - «модель не скачалась» — попытка начать диктовку отклонена (модель не READY),
* микрофон не открыт; ниже живая строка прогресса ([modelStatusLine]);
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт;
* - при «ответе виден» — последняя фраза как контекст (или null).
* - «не расслышал» — стоп/отправка не поймала голоса (пустой клик);
* - кадр ассистента (idle/думаю без ответа/ответ виден) — подсказка жестов
* (клик — говорить, двойной — выход); чат прокручивается под заголовком.
*/
fun overlayHeader(state: AssistantState): String? = when {
state.sttStartBlocked -> "модель не скачалась"
state.thinking && state.assistantText.isBlank() -> "думаю..."
state.listening -> state.sttText.ifBlank { "слушаю..." }
state.top == NavFrame.Listening -> state.sttText.ifBlank { "слушаю..." }
state.sttNoSpeech -> "не расслышал — клик, чтобы повторить"
state.top == NavFrame.Assistant -> "клик — говорить · двойной — выход"
else -> state.sttText.takeIf { it.isNotBlank() }
}
/**
* Строка статуса модели 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].
@@ -41,7 +41,7 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
// + свежее значение от телефона); без видео — ничего не выводить.
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState()
val movieActive = GlassesApp.instance.rootLayer.controller.movieActive.collectAsState()
Row(
modifier = modifier
.background(Color(0x99000000)) // полупрозрачный чёрный
@@ -0,0 +1,43 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import pw.binom.asr.qwen3.Qwen3Config
import pw.binom.asr.qwen3.createQwen3Sherpa
import pw.binom.viewmate.glasses.log
/**
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
* [SttModelDownloader]).
*/
class GlassesAsr(
modelsDir: File,
numThreads: Int = 4,
) {
private val modelsDir = modelsDir
private val numThreads = numThreads
/**
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
*/
private val recognizer by lazy {
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
createQwen3Sherpa(
Qwen3Config(
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
numThreads = numThreads,
),
)
}
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
runCatching { recognizer.recognize(samples, sampleRate) }
.onFailure { log("stt", "Qwen3 recognizer упал: ${it.javaClass.simpleName}: ${it.message}") }
.getOrDefault("")
}
@@ -0,0 +1,31 @@
package pw.binom.viewmate.glasses.stt
/**
* Абстракция механизма распознавания очков: готов ли механизм + распознать фразу.
*
* [GlassesSttSession] работает с этим интерфейсом, а не с конкретным движком — так
* дальше за ним можно подключить другой механизм (например, удалённое распознавание
* на телефоне) без изменения сессии.
*/
interface GlassesRecognizer {
/** Механизм готов (модель докачана / нативка загрузится). false — не открывать микрофон. */
fun isReady(): Boolean
/** Распознать фразу (FloatArray, 16 кГц mono). Пустая строка — тишина / не готово. */
fun recognize(phrase: FloatArray): String
}
/**
* Единственная реализация: локальный механизм распознавания очков — Qwen3-ASR
* ([GlassesAsr]); готовность берёт из статуса скачанной модели ([SttModelDownloader]).
* Телефон в распознавании не участвует.
*/
class LocalGlassesRecognizer(
private val asr: GlassesAsr,
private val model: SttModelDownloader,
) : GlassesRecognizer {
override fun isReady(): Boolean = model.status.value.ready
override fun recognize(phrase: FloatArray): String = asr.recognize(phrase)
}
@@ -0,0 +1,154 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.sqrt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.stt.s16leToFloat
import pw.binom.viewmate.core.stt.splitPhrases
import pw.binom.viewmate.core.stt.trimEdges
import pw.binom.viewmate.glasses.log
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
interface GlassesMic {
fun start(filesDir: File, shouldRun: () -> Boolean)
fun stop()
}
/**
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
* локально (через [recognizer]) и возвращаем готовый текст — телефон больше
* не участвует в распознавании. UX остаётся «говори → клик → текст».
*
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
* пусто после 30s-лимита (timeout) — автозавершение.
*/
class GlassesSttSession(
private val filesDir: File,
private val scope: CoroutineScope,
private val recognizer: GlassesRecognizer,
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
) {
/**
* Обработчик готового текста ([finish] → [onResult] на главном потоке). Сессия лишь
* *порождает* результат — куда он уходит, решает владелец: слой записи
* (ui.layers.ListeningLayout) регистрирует в onEnter, сбрасывает в onExit
* (`onResult = null` — слоя нет → результата некому принять).
*/
var onResult: ((text: String, auto: Boolean) -> Unit)? = null
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
private val sessionLimitMs = 30_000L
private var mic: GlassesMic? = null
/**
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
*/
private val pcmLock = ReentrantLock()
private var pcm = ByteArray(0)
private var watchdog: Job? = null
@Volatile
private var running = false
val isRunning: Boolean get() = running
/**
* Начать слушать: чистый буфер + микрофон + таймер автозавершения.
* Гейт в момент ПОПЫТКИ начать сбор: если механизм ещё не готов ([GlassesRecognizer.isReady]=false),
* микрофон НЕ открываем и PCM не накапливаем — сбор не начинается, распознавание
* гарантированно не будет пустым «потом». Ошибку показывает UI (sttStartBlocked).
*/
fun start() {
if (running) return
if (!recognizer.isReady()) {
log("stt", "STT-сессия очков: механизм не готов — микрофон не открываю, сбор не начинаю")
return
}
running = true
pcmLock.withLock { pcm = ByteArray(0) }
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
mic = m
m.start(filesDir) { running }
watchdog = scope.launch {
delay(sessionLimitMs)
finish(auto = true)
}
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
}
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
fun send() = finish(auto = false)
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
fun cancel() {
stopMic()
watchdog?.cancel()
pcmLock.withLock { pcm = ByteArray(0) }
log("stt", "STT-сессия очков: отмена")
}
/** Полный стоп (выход из режима ассистента). */
fun stop() {
running = false
stopMic()
watchdog?.cancel()
pcmLock.withLock { pcm = ByteArray(0) }
log("stt", "STT-сессия очков: закрыта")
}
private fun finish(auto: Boolean) {
if (!running) return
running = false
stopMic()
watchdog?.cancel()
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
scope.launch(Dispatchers.Default) {
val text = if (audio.isNotEmpty() && recognizer.isReady()) {
val floats = s16leToFloat(audio)
var peak = 0f
for (f in floats) { val a = if (f < 0f) -f else f; if (a > peak) peak = a }
val win = 320
var w005 = 0; var w001 = 0
var i = 0
while (i + win <= floats.size) {
var sum = 0.0
for (k in i until i + win) sum += floats[k] * floats[k].toDouble()
val rms = sqrt(sum / win).toFloat()
if (rms >= 0.005f) w005++
if (rms >= 0.01f) w001++
i += win
}
val trimmed = trimEdges(floats)
val phrases = splitPhrases(trimmed)
log("stt", "diag pcm=${audio.size / 32}мс peak=${peak} окно[≥.005]=${w005} [≥.01]=${w001} trim=${trimmed.size / 32000}с фраз=${phrases.size}")
val joined = phrases.mapIndexed { n, p ->
val r = recognizer.recognize(p)
log("stt", "diag фраза${n + 1} ${p.size * 1000 / 16000}мс → '${r.take(40)}'")
r
}.joinToString(" ")
if (joined.isEmpty()) log("stt", "diag: результат пустой")
joined
} else {
log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено")
""
}
withContext(Dispatchers.Main) { onResult?.invoke(text, auto) }
}
}
private fun stopMic() {
mic?.stop()
mic = null
}
}
@@ -30,13 +30,13 @@ import kotlin.math.roundToInt
class SttMicStream(
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,8 +32,11 @@ 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
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.ListeningLayout
/**
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
@@ -46,15 +49,34 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset
* [GlassesApp.answerScrollDelta]).
*/
@Composable
fun AssistantOverlay() {
fun AssistantOverlay(engine: AssistantEngine) {
val app = GlassesApp.instance
val listening by app.listening.collectAsState()
val thinking by app.thinking.collectAsState()
val sttText by app.sttText.collectAsState()
val assistantText by app.assistantText.collectAsState()
val chatHistory by app.chatHistory.collectAsState()
val state = AssistantState(listening, thinking, sttText, assistantText)
// Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine].
val navStack by app.navStack.collectAsState()
val thinking by engine.thinking.collectAsState()
// Живой текст распознавания владеет слой записи ([ListeningLayout] — единственный, кто в курсе
// микрофона): читаю из верхнего слоя, не из движка. В локальном режиме он vestigial (заполняется
// на результат и скрывается сразу за POP-ом слоя), поэтому реактивность не критична — значение
// перечитывается на каждую рекомпозицию (нав-стек + статусы ассистента выше).
val sttText = (app.layoutManager.current as? ListeningLayout)?.sttText?.value.orEmpty()
val assistantText by engine.assistantText.collectAsState()
val sttNoSpeech by engine.sttNoSpeech.collectAsState()
val chatHistory by engine.chatHistory.collectAsState()
// «Модель не скачалась» выводится из состояния догрузчика (отдельного логического флага в
// движке нет): распознавание локально, поэтому модель не READY = распознавание не готово —
// объясняем что происходит (качается / не скачалась).
val modelStatus by app.stt.status.collectAsState()
val state = AssistantState(
stack = navStack,
thinking = thinking,
sttText = sttText,
assistantText = assistantText,
sttStartBlocked = !modelStatus.ready,
sttNoSpeech = sttNoSpeech,
)
if (!overlayVisible(state)) return
// Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ модель не READY («не скачалась»).
val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
// в единственном экземпляре — иначе глаза скроллят по-разному.
@@ -69,19 +91,19 @@ fun AssistantOverlay() {
}
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
// аккумулятор (answerScrollDelta) общий в [engine]. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) {
app.answerScrollDelta.collect { pending ->
engine.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
app.answerScrollDelta.value = 0f
engine.answerScrollDelta.value = 0f
if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150))
}
}
}
overlayBox(app, state, chatHistory, scrollState)
overlayBox(app, state, chatHistory, scrollState, modelLine)
}
/**
@@ -97,6 +119,7 @@ private fun overlayBox(
state: AssistantState,
chatHistory: List<ChatEntry>,
scrollState: ScrollState,
modelLine: String?,
) {
Binocular(scale = false) {
val scale by app.scale.collectAsState()
@@ -108,7 +131,9 @@ private fun overlayBox(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
// Полупрозрачный: затемнённый фильм (DimScrim снизу) просвечивает позади чата —
// «чат виден на фоне». Чат сам по себе тёмный, читаемость не падает.
.background(Color.Black.copy(alpha = 0.88f)),
) {
Box(
modifier = Modifier
@@ -119,9 +144,9 @@ private fun overlayBox(
// вертикаль (vertical) — только для видео; оверлей всегда у верха
),
) {
AssistantOverlayContent(state, chatHistory, scrollState)
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
if (state.listening) {
if (state.listening && !state.sttStartBlocked) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
@@ -140,6 +165,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 +186,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(
@@ -0,0 +1,42 @@
package pw.binom.viewmate.glasses.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.LayoutManager
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
/**
* Ленточный (LIFO) layout-стек очков: композитинг снизу вверх — [LayoutManager.compositing]
* рендерит ВСЕ слои (кино → затемнение → чат → слушаю), панель статуса у верхнего.
* Стек правды — персистентный [GlassesApp.layoutManager]; [navStack] — лишь его зеркало
* (для триггера рекомпозиции).
*/
@Composable
fun LayoutStackContent(manager: LayoutManager) {
// Триггер рекомпозиции: стек менеджера меняется через ctx.open/close, а LayoutManager.onChange
// зеркалирует его в [GlassesApp.navStack] (StateFlow). compositing() читает сам стек менеджера
// (deque); изменение navStack запускает рекомпозицию.
val nav by GlassesApp.instance.navStack.collectAsState()
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
manager.compositing().forEach { layer ->
layer.layout.drawStage()
// Затемняем фильм, только когда поверх него что-то есть (чтение nav — и триггер).
if (layer.layout is MoveLayout && nav.size > 1) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
)
}
}
manager.current.drawStatusPanel()
}
}
@@ -0,0 +1,134 @@
package pw.binom.viewmate.glasses.ui.layers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import pw.binom.viewmate.core.protocol.SttText
import pw.binom.viewmate.glasses.ChatEntry
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.gestures.NavFrame
/**
* Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные
* flows в [GlassesApp]. Держит ТОЛЬКО диалог ассистента с телефоном ([thinking]/[assistantText]/
* [chatHistory]/[answerScrollDelta]) и его реакции на ввод ([continueTurn]/[stopToIdle]).
*
* Микрофон и распознавание движку НЕ ЗНАЕТ: всё стт живёт в [ListeningLayout] (единственный, кто
* в курсе микрофона и распознавания); для движка ввод — просто «текст пришёл» ([continueTurn]) или
* «ввод отменён» ([stopToIdle]). Чат-оверлей читает стейт тоже отсюда.
*
* Зависимости (протокол, менеджер стека) берёт лениво из [GlassesApp] в методах — сам движок
* строится без побочных эффектов (его стейт — лишь flows), порядок инициализации полей не критичен.
*/
class AssistantEngine(private val app: GlassesApp) {
// ── Стейт диалога ассистента (был размазанными полями в GlassesApp) ───────────────
/** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */
val thinking = MutableStateFlow(false)
/** Текст ответа ассистента (ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/**
* У ассистента «не расслышал»: последний ввод отменили без голоса — оверлей показывает
* «не расслышал, тапни ещё раз» (idle-hint). Это стейт *отображения* ассистента (факт
* отмены несёт [stopToIdle]), не стт-факт — распознанным текстом движок не владеет.
*/
val sttNoSpeech = MutableStateFlow(false)
/** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */
val answerScrollDelta = MutableStateFlow(0f)
/** Скоуп корутин, которые запускают вызывающие слои (явное ожидание результата записи). */
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// ── Под-состояние кадра ассистента (top == Assistant) ─────────────────────────────
/** idle / thinking / answer — по стейту (top кадра ассистента предполагается активным). */
fun mode(): AssistantMode = when {
assistantText.value.isNotBlank() -> AssistantMode.ANSWER
thinking.value -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
// ── Эффекты (перенесены из GlassesGestureEffects) ────────────────────────────────
/**
* Кадр ассистента открыт ([AssistantLayout.onEnter]) — чистый старт сессии: стираем прошлый
* ответ / «не расслышал» / дельту скролла. Паузу фильма и синх позиций делает ВЛАДЕЛЕЦ (слой
* кино), а не движок: здесь чистится только *свой* стейт.
*/
fun beginSession() {
sttNoSpeech.value = false
assistantText.value = ""
answerScrollDelta.value = 0f
}
/** Логика «думает ассистент или нет»: сотереть показанный ответ и дельту скролла перед новой фразой. */
fun clearAnswer() {
assistantText.value = ""
answerScrollDelta.value = 0f
}
/**
* Ввод принят ([ListeningLayout.onSuccess] → вызывающий [AssistantLayout]): жду ответ
* ([thinking]). Слой записи сам себя POP-нул (ctx.close), его onExit закрыл микрофон —
* движок микрофоном не владеет, стек здесь не трогаем. «Продолжить ход» диалога с
* телефоном = отправить распознанный ввод на LLM ([SttText]) и ожидать ответ.
*/
suspend fun continueTurn(text: String) {
log("stt", "ассистент: фраза «${text.take(40)}» → на телефон (LLM), жду ответ")
app.hostConnection.sendToHost(SttText(text))
thinking.value = true
}
/**
* Ввод отменён ([ListeningLayout.onCancel] → вызывающий [AssistantLayout]): стоп, остаюсь в
* ассистенте (idle). [sttNoSpeech] — idle-hint «не расслышал» (факт отмены, не стт-текст).
*/
fun stopToIdle(reason: String, noSpeech: Boolean) {
log("stt", reason)
thinking.value = false
sttNoSpeech.value = noSpeech
}
/** Выход из режима в корень: closeToRoot (слои POP-нуты, их onExit сам закроет своё), чистим стейт. */
fun exitMode(reason: String) {
log("stt", reason)
app.layoutManager.closeToRoot()
thinking.value = false
sttNoSpeech.value = false
assistantText.value = ""
answerScrollDelta.value = 0f
}
// ── Host-driven записи стейта (вместо прямого писания flows в GlassesApp) ──────────
/** ShowText: ответ ассистента виден, «думаю» сброшено. */
fun onShowText(text: String) {
thinking.value = false
answerScrollDelta.value = 0f
assistantText.value = text
}
/** ChatHistory: история сессии с телефона (фильтрация wire-типов — в HostConnection). */
fun onChatHistory(messages: List<ChatEntry>) {
chatHistory.value = messages
}
}
/** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */
fun Layout.toFrame(): NavFrame = when (this) {
is MoveLayout -> NavFrame.Film
is AssistantLayout -> NavFrame.Assistant
is ListeningLayout -> NavFrame.Listening
else -> NavFrame.Film
}
@@ -0,0 +1,86 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.runtime.Composable
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.stt.GlassesStt
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import kotlin.coroutines.resume
/**
* Кадр ассистента (чат). Полупрозрачный [AssistantOverlay] — затемнённый фильм просвечивает
* на фоне (фильм остаётся ниже в композите). В под-состоянии «слушаю» оверлей сам показывает
* индикатор записи и текущую фразу — отдельного экрана «слушаю» нет, чат остаётся на фоне.
*
* В кадре остаётся только *его* логика: скролы (свайпы), «думает ассистент или нет» и реакция на
* результат записи. Записью микрофона кадр НЕ занимается: клик *вызывает* слой записи
* ([ListeningLayout]) suspend-функцией [listen] и явно ждёт её окончания (текст / отмена);
* сам микрофон запись открывает/закрывает в своём onEnter/onExit.
*/
class AssistantLayout(
private val ctx: LayoutContext,
private val engine: AssistantEngine,
private val stt: GlassesStt,
) : Layout {
@Composable
override fun drawStage() {
AssistantOverlay(engine)
}
@Composable
override fun drawStatusPanel() = Unit
/** Кадр ассистента открыт: чистый старт сессии — стираем прошлый ответ / «не расслышал» / дельту скролла. */
override fun onEnter() {
engine.beginSession()
}
/**
* Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк);
* логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ).
*/
override fun gesture(g: Gesture) {
when (g) {
Gesture.DoubleClick -> engine.exitMode("DoubleClick → выход из чата (кино остаётся на паузе)")
Gesture.Click -> when (engine.mode()) {
AssistantMode.IDLE -> openListening()
AssistantMode.THINKING, AssistantMode.ANSWER -> {
engine.clearAnswer()
openListening()
}
else -> Unit // NONE/LISTENING при top==Assistant невозможны (тогда топ был бы другим)
}
Gesture.TripleClick -> if (engine.mode() == AssistantMode.IDLE) openListening()
Gesture.SwipeForward, Gesture.SwipeBackward -> Unit // логика скролов: в диалоге не скроллим
}
}
/**
* Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания.
* Возвращает распознанный текст ([onSuccess]) или null ([onCancel], любая отмена) —
* слой записи завершает ожидание этими кэлбэками.
*/
private suspend fun listen() = ListeningLayout.listen(ctx, stt)
/** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */
private fun openListening() {
engine.scope.launch {
val text = listen()
if (text != null) {
engine.continueTurn(text) // распознали фразу → жду ответ (thinking); слой записи сам себя POP-нул
} else {
engine.stopToIdle(
"отмена ввода → остаюсь в ассистенте",
noSpeech = true
) // отмена → idle
}
}
}
}
@@ -0,0 +1,140 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.stt.GlassesStt
import kotlin.coroutines.resume
/**
* Слой записи — ЕДИНСТВЕННЫЙ, кто знает про микрофон и распознавание. Он сам открывает
* микрофон ([stt.session.start] в [onEnter]), закрывает ([onExit]) и сам принимает
* стт-решение: клик распознаёт накопленное ([stt.session.send]), локальный результат
* ([onSttResult]) слой переваривает сам и сообщает вызывающему факт исхода кэлбэками
* [onSuccess]/[onCancel]. Хостовых входов нет — в локальной архитектуре слой самозамкнут.
*
* Для всего остального (кино, чат, ассистент) «текст откуда-то берётся» — никто кроме этого
* слоя не в курсе микрофона и распознавания. Зависимость — единый объект [GlassesStt] (весь
* стек распознавания: скачивание, статус, сессия микрофона), а не весь [GlassesApp];
* исхода двух видов: успех — [onSuccess] с текстом, ЛЮБАЯ отмена (двойной тап, тишина,
* модель не готова) — [onCancel]; выхода из режима слой не делает — это уже эффект вызывающего.
*/
class ListeningLayout(
private val ctx: LayoutContext,
private val stt: GlassesStt,
private val onSuccess: (String) -> Unit,
private val onCancel: () -> Unit,
) : Layout {
companion object {
suspend fun listen(
ctx: LayoutContext,
stt: GlassesStt,
) = suspendCancellableCoroutine { context ->
ctx.open { c ->
context.invokeOnCancellation {
c.close()
}
ListeningLayout(c, stt, onSuccess = { text ->
context.resume(text)
}, onCancel = {
context.resume(null)
})
}
}
}
/** Накопленный распознанный текст — живой стейт слоя (оверлей читает через вершину стека). */
val sttText = MutableStateFlow("")
private val modelReady: Boolean get() = stt.status.value.ready
/** Прозрачный слой: сам ничего не рисует — оверлей ассистента (поверх) показывает индикатор и фразу. */
@Composable
override fun drawStage() = Unit
@Composable
override fun drawStatusPanel() = Unit
/** Слой открыт: открыть микрофон (само гейтится готовностью модели: не ready → старт молчит). */
override fun onEnter() {
sttText.value = ""
// Регистрирую себя обработчиком готового текста: слой сам заводит свой результат,
// сессия лишь порождает текст — куда он идёт, знает только владелец (этот слой).
stt.session.onResult = { text, auto -> onSttResult(text, auto) }
stt.session.start()
log("stt", "слои: вхожу в режим записи, стартую сессию + микрофон")
}
/** Слой закрыт: микрофон закрыть + безопасная отмена ожидания (resume однократен, повтор — no-op). */
override fun onExit() {
stt.session.onResult = null
stt.session.stop()
onCancel()
}
/**
* Жесты слоя: клик — распознать накопленное (модель не ready — повторить попытку старта);
* двойной — отмена (стоп, остаюсь в ассистенте) или выход (модель не скачалась). Остальное — игнор.
*/
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click ->
if (modelReady) {
log("stt", "клик → распознаю накопленное")
stt.session.send()
} else {
log("stt", "клик: модель не готова — повторю старт записи")
stt.session.start()
}
Gesture.DoubleClick ->
if (modelReady) {
log("stt", "двойной → отмена (стоп, остаюсь в ассистенте)")
onCancel()
ctx.close()
} else {
log("stt", "двойной: модель не скачалась → отмена, остаюсь в ассистенте")
onCancel()
ctx.close()
}
Gesture.TripleClick,
Gesture.SwipeForward,
Gesture.SwipeBackward -> Unit
}
}
/**
* Обработчик результата ЛОКАЛЬНОГО распознавания. Слой сам заводит свой результат:
* регистрируется на [stt.session.onResult] в [onEnter], сбрасывает в [onExit] —
* извне (телефон/LLM-путь) [onSttResult] не вызывают. Слой сам решает исход:
* - текст не пуст → успех ([onSuccess]) + POP слоя;
* - пусто + авто (тишина 30с) → отмена ([onCancel]) + POP слоя;
* - пусто + клик (без голоса) → отмена ([onCancel]) + POP слоя (не расслышал).
* Текст дальше «уходит от куда-то» (в LLM на телефон) — слой это не видит.
*/
private fun onSttResult(text: String, auto: Boolean) {
sttText.value = text
when {
text.isNotBlank() -> {
log("stt", "распознал: «$text» → отправляю ассистенту")
onSuccess(text)
ctx.close()
}
else -> {
log(
"stt",
"без голоса (${if (auto) "тишина 30с" else "пустой клик"}) → отмена, остаюсь в ассистенте"
)
onCancel()
ctx.close()
}
}
}
}
@@ -0,0 +1,90 @@
package pw.binom.viewmate.glasses.ui.layers
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.glasses.ConnectionScreen
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.MovieController
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
/**
* Кино — корень стека. Рисуется всегда (нижний слой композита): активный фильм —
* [GlassesMovieScreen], иначе экран подключения [ConnectionScreen]. Поверх него LayoutManager
* кладёт затемнение + под-слои (ассистент/слушание), поэтому фильм виден на фоне чата.
*
* Слой *владеет* своим медиастейтом ([controller]: плеер/позиция/wifi-lock) — он живёт со
* стопом корня, а не с жизненным циклом Activity. Протокол/lifecycle/эффекты дотягиваются до
* движка через слой ([controller]). Слой *сам* отвечает за свои жесты: тройной клик — пауза +
* открыть ассистента, клик — плей/пауза, свайпы — seek. Открытие под-слоёв — через [ctx].
*/
class MoveLayout(
private val ctx: LayoutContext,
context: Context,
) : Layout {
/** Медиадвижок слоя: [MoveLayout] — владелец (корень живёт до конца сессии очков). */
val controller = MovieController(context)
@Composable
override fun drawStage() {
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) GlassesMovieScreen(player = player, title = title)
else ConnectionScreen()
}
@Composable
override fun drawStatusPanel() = Unit
/** Жесты кино (слой активен, когда он вершина стека): каждый жест ведёт себя сам. */
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click -> togglePlayback()
Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore)
Gesture.TripleClick -> callAssistant()
Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000)
Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000)
}
}
/** Click (кино): плей/пауза + отправить позицию на телефон. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
controller.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** Свайп (кино): seek на ±[ms]. */
private fun seekBy(cmd: MediaCommand, ms: Long) {
log("gesture", "жест: свайп → $cmd ${ms}ms")
controller.applyMediaCommand(MediaCommandMsg(cmd, ms)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/**
* Тройной клик (кино): *слое-зависимые* эффекты кино — пауза + синх позиций на телефон — и
* штатно открыть под-слой ассистента ([AssistantLayout]) через [ctx]. Свой стейт сессии кадр
* чистит в onEnter ([AssistantEngine.beginSession]); движок из слоя кино не вызывается.
*/
private fun callAssistant() {
log("gesture", "жест: TripleClick → ассистент (стек: фильм → ассистент)")
val app = GlassesApp.instance
if (controller.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
app.hostConnection.sendPlaybackPositionNow()
ctx.open { c ->
AssistantLayout(c, app.assistantEngine, app.stt)
}
}
}
@@ -0,0 +1,168 @@
package pw.binom.viewmate.glasses
import androidx.compose.runtime.Composable
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* [LayoutManager] — ЛIFO-стек layout-ов очков: корень (двойным кликом не вытолкнуть) → под-слои.
* Проверяем: open/close/closeToRoot, вершина, композитинг (затемнение между слоями), индикатор
* записи, маршрутизацию жеста в верхний слой (и только в него).
*/
class LayoutManagerTest {
/** Тестируемый layout: пустые @Composable тела (в юнит-тестах не компонируются), лог жеста. */
private class FakeLayout(
val id: String,
private val onGesture: (Gesture) -> Unit = {},
) : Layout {
val gestures = mutableListOf<Gesture>()
@Composable
override fun drawStage() {}
@Composable
override fun drawStatusPanel() {}
override fun gesture(g: Gesture) {
gestures += g
onGesture(g)
}
}
private val root = FakeLayout("film")
private val assistant = FakeLayout("assistant")
@Test
fun `root is the initial current`() {
val m = LayoutManager { _ -> root }
assertEquals(1, m.depth)
assertIs<FakeLayout>(m.current)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `open pushes a child on top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
assertEquals(2, m.depth)
assertEquals("assistant", (m.current as FakeLayout).id)
assertEquals(listOf("film", "assistant"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `close pops to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `close on root is a no-op`() {
val m = LayoutManager { _ -> root }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `closeToRoot drops every non-root layer`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
assertEquals(3, m.depth)
m.closeToRoot()
assertEquals(1, m.depth)
assertEquals(listOf("film"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `compositing dims every layer but the top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
val layers = m.compositing()
assertEquals(3, layers.size)
// film → dim, assistant → dim, listening → без затемнения.
assertEquals(listOf("film", "assistant", "listening"), layers.map { (it.layout as FakeLayout).id })
assertEquals(listOf(true, true, false), layers.map { it.dim })
}
@Test
fun `single layer composites without dim`() {
val m = LayoutManager { _ -> root }
val layers = m.compositing()
assertEquals(listOf(false), layers.map { it.dim })
}
@Test
fun `dispatch routes only to the top layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.dispatch(Gesture.Click)
// Вершина (assistant) получила жест, корень — нет.
assertEquals(listOf(Gesture.Click), assistant.gestures)
assertEquals(0, root.gestures.size)
}
@Test
fun `dispatch after close routes back to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
m.dispatch(Gesture.DoubleClick)
assertEquals(0, assistant.gestures.size)
assertEquals(listOf(Gesture.DoubleClick), root.gestures)
}
@Test
fun `recording indicator reflects an active layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
assertFalse(m.recordingIndicator())
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.setRecordingIndicator(false)
assertFalse(m.recordingIndicator())
}
@Test
fun `popping a recording layer turns the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.close()
// assistant (POP) выключил индикатор — корень его не включал.
assertFalse(m.recordingIndicator())
}
@Test
fun `a re-opened layer starts with the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
m.close()
m.open { FakeLayout("assistant2") }
// Новый экземпляр не наследует включение — индикатор выключен, пока сам не включит.
assertFalse(m.recordingIndicator())
}
@Test
fun `child receives the shared context`() {
var received: LayoutContext? = null
val m = LayoutManager { _ -> root }
m.open { ctx -> received = ctx; assistant }
assertSame(m, received)
assertTrue(m.recordingIndicator() == false)
}
private fun assertSame(a: Any?, b: Any?) {
if (a !== b) throw AssertionError("ожидал тот же экземпляр контекста")
}
}
@@ -15,36 +15,46 @@ import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.glasses.stt.SttModelStatus
/**
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
* Карта жеста (docs/gestures-and-modes.md) — ЛIFO-стек кадров:
* [NavFrame.Film] (корень) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон).
* Двойной клик POP-ает верхний кадр; корень не вытолкнуть. Плюс свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея.
*/
class AssistantGesturesTest {
private val movie = AssistantState()
private val listening = AssistantState(listening = true)
private val thinking = AssistantState(thinking = true)
private val answer = AssistantState(assistantText = "ответ")
// Стек-состояния (вершина — текущий кадр). Корень всегда [NavFrame.Film].
private val movie = AssistantState() // [Film]
private val idle = AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant))
private val listening =
AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening))
private val thinking = idle.copy(thinking = true)
private val answer = idle.copy(assistantText = "ответ")
private val blocked = listening.copy(sttStartBlocked = true)
// ————— режим по состоянию —————
// ————— режим по вершине стека —————
@Test
fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
}
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
/** Вершина Listening всё побеждает — жесты идут по матрице «слушаю». */
@Test
fun listeningWinsOverEverything() {
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
fun listeningTopWins() {
val s = listening.copy(thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
/** «Ответ виден»: вершина Assistant, assistantText непуст — даже при thinking. */
@Test
fun answerWinsOverThinking() {
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
assertEquals(
AssistantMode.ANSWER,
assistantMode(idle.copy(thinking = true, assistantText = "а")),
)
}
@Test
@@ -52,7 +62,12 @@ class AssistantGesturesTest {
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
}
// ————— кино ( NONE) —————
@Test
fun idleWhenAssistantTopNoContent() {
assertEquals(AssistantMode.IDLE, assistantMode(idle))
}
// ————— кино (вершина Film) —————
@Test
fun movieMatrix() {
@@ -77,12 +92,12 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
}
// ————— слушаю —————
// ————— слушаю (вершина Listening) —————
@Test
fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.StopListening, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
@@ -90,7 +105,16 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
}
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
/** Модель не скачалась: кадр Listening на месте, микрофон закрыт — клик повторить, двойной выход. */
@Test
fun listeningBlockedMatrix() {
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), blocked))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), blocked))
}
// ————— «думаю» / «ответ» (вершина Assistant) —————
@Test
fun thinkingMatrix() {
@@ -101,27 +125,87 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
}
// ————— «ответ виден» —————
@Test
fun answerMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
// Скролла ответа больше нет (решение пользователя: в диалоге ассистента не скроллим) —
// все свайпы (дискретные и плавный шлейф) игнорируются.
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
}
// ————— стек-переходы —————
/** Тройной клик в кино: кладём «ассистент» и сразу «слушаю» — [Film, Assistant, Listening]. */
@Test
fun answerPageDeltaIsParameter() {
assertEquals(
GestureDecision.ScrollAnswer(80f),
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
)
fun enterAssistantPushesBothFrames() {
val s = movie.enterAssistant()
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), s.stack)
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** Click в idle/«думаю»/«ответ»: кладём «слушаю» поверх кадра ассистента. */
@Test
fun beginListeningFramePushesListening() {
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), idle.beginListeningFrame().stack)
assertEquals(AssistantMode.LISTENING, assistantMode(answer.beginListeningFrame()))
}
/** DoubleClick в «слушаю» — стоп: POP кадра, остаёмся в ассистенте (idle), чат не прерван. */
@Test
fun stopListeningStaysInAssistant() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.StopListening, decision)
// POP кадра «слушаю» — кадр ассистента (idle), оверлей остаётся, выхода нет.
val after = listening.stopListeningFrame(noSpeech = false)
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant), after.stack)
assertEquals(AssistantMode.IDLE, assistantMode(after))
assertTrue(overlayVisible(after))
assertFalse(after.listening)
// Idle: Click/Triple — возобновить запись; DoubleClick — POP ассистента (выход к фильму).
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), after))
assertEquals(GestureDecision.BeginListening, gestureDecision(TripleClick(), after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
}
/** Стоп первой записи (пустая история): оверлей НЕ закрывается — остаёмся в idle-кадре. */
@Test
fun stopFirstRecordingKeepsOverlay() {
val stopped = listening.copy(sttNoSpeech = true).stopListeningFrame(noSpeech = true)
assertTrue(stopped.assistantText.isBlank())
assertFalse(stopped.listening)
// Оверлей виден (idle-кадр), хотя ни фразы, ни ответа нет.
assertTrue(overlayVisible(stopped))
// И только явный выход (POP до фильма) прячет оверлей.
val exited = stopped.exitToFilm()
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** Стоп сбрасывает накопленный текст текущей (останавливаемой) записи. */
@Test
fun stopListeningClearsCancelledText() {
val after = listening.copy(sttText = "слова до отмены").stopListeningFrame(noSpeech = false)
assertEquals("", after.sttText)
assertFalse(after.listening)
}
/** Выход: POP всё до корня (фильм), сброс контента. */
@Test
fun exitToFilmPopsToRoot() {
val exited =
listening.copy(sttText = "ф", thinking = true, assistantText = "а", sttNoSpeech = true)
.exitToFilm()
assertEquals(listOf(NavFrame.Film), exited.stack)
assertFalse(overlayVisible(exited))
assertTrue(exited.assistantText.isBlank())
assertFalse(exited.thinking)
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@@ -130,9 +214,9 @@ class AssistantGesturesTest {
fun sessionTransitions() {
var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.copy(listening = true)
s = s.enterAssistant()
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
s = s.stopListeningFrame(noSpeech = false).copy(thinking = true) // фраза отправлена, ответ ещё нет
assertEquals(AssistantMode.THINKING, assistantMode(s))
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
@@ -140,77 +224,42 @@ class AssistantGesturesTest {
assertEquals(AssistantMode.ANSWER, assistantMode(s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
// Выход: оверлей невидим, кино снова управляется кликом.
val exited = s.copy(assistantText = "")
val exited = s.exitToFilm()
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
/** Корень «фильм»: двойным кликом попу нельзя; вход в ассистент — Triple. */
@Test
fun cancelStaysInConversation() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.CancelDictation, decision)
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
val after = afterCancelDictation(listening)
assertEquals(AssistantMode.LISTENING, assistantMode(after))
assertTrue(overlayVisible(after))
// Двойной тап в «слушаю» после отмены — снова просто отмена новой записи.
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), after))
// Отмена посреди диалога: пользователь говорит новую фразу → ответ виден
// (listening уже закрыт отправкой) — выход (как и раньше) DoubleClick.
val afterWithAnswer = after.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(afterWithAnswer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), afterWithAnswer))
}
/** Отмена первой записи (пустая история): оверлей НЕ закрывается (TZ-ui-four п.1). */
@Test
fun cancelFirstRecordingKeepsOverlay() {
// Чистый «кино»-снимок → «слушаю» (вызов ассистента) → отмена записи.
val listeningNow = movie.copy(listening = true)
val cancelled = afterCancelDictation(listeningNow)
assertTrue(cancelled.assistantText.isBlank())
assertTrue(cancelled.listening)
// Оверлей виден, хотя ни фразы, ни ответа нет — «слушай готов».
assertTrue(overlayVisible(cancelled))
// И только явный выход (после диалога) прячет оверлей.
val exited = cancelled.copy(listening = false, thinking = false, assistantText = "ответ")
val closed = exited.copy(listening = false, thinking = false, assistantText = "")
assertFalse(overlayVisible(closed))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), closed))
}
/** Отмена сбрасывает накопленный текст текущей (отменяемой) записи. */
@Test
fun cancelDictationClearsCancelledText() {
val after = afterCancelDictation(listening.copy(sttText = "слова до отмены"))
assertEquals("", after.sttText)
assertTrue(after.listening)
fun filmRootCannotBePopped() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
}
// ————— оверлей —————
@Test
fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
// Настоящее кино-состояние: стек [Film], оверлей невидим.
assertFalse(overlayVisible(movie))
}
@Test
fun overlayVisibleInListeningThinkingAndAnswer() {
fun overlayVisibleInAssistantFrames() {
assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer))
assertTrue(overlayVisible(idle))
}
@Test
fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking))
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
// После отправки фразы: вершина ещё Listening + thinking — тоже «думаю...».
assertEquals(
"думаю...",
overlayHeader(AssistantState(listening = true, thinking = true)),
overlayHeader(listening.copy(thinking = true)),
)
}
@@ -221,10 +270,64 @@ class AssistantGesturesTest {
}
@Test
fun answerHeaderIsLastPhraseOrNull() {
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
assertNull(overlayHeader(answer))
assertNull(overlayHeader(movie))
fun idleHeaderHintsGestures() {
assertEquals("клик — говорить · двойной — выход", overlayHeader(idle))
// В ответе подсказка та же — чат прокручивается, жесты те же.
assertEquals("клик — говорить · двойной — выход", overlayHeader(answer))
}
/** Пустой клик без голоса — idle-кадр с «не расслышал». */
@Test
fun noSpeechIdleShowsHint() {
val noSpeech = idle.copy(sttNoSpeech = true)
assertEquals(AssistantMode.IDLE, assistantMode(noSpeech))
assertEquals("не расслышал — клик, чтобы повторить", overlayHeader(noSpeech))
assertTrue(overlayVisible(noSpeech))
}
// ————— старт отклонён: модель не скачалась —————
@Test
fun overlayVisibleWhenStartBlocked() {
assertTrue(overlayVisible(blocked))
assertFalse(overlayVisible(movie))
}
@Test
fun startBlockedHeaderIsModelNotDownloaded() {
// Блок бьёт и обычный «думаю...» — заголовок всегда «модель не скачалась».
assertEquals("модель не скачалась", overlayHeader(blocked))
assertEquals(
"модель не скачалась",
overlayHeader(blocked.copy(assistantText = "ответ")),
)
}
// ————— строка статуса модели —————
@Test
fun modelStatusLineReadyIsNull() {
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
}
@Test
fun modelStatusLineDownloadingShowsPercent() {
assertEquals(
"модель качается: 45%",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
)
}
@Test
fun modelStatusLineMissingAndFailed() {
assertEquals(
"модель не скачана — распознавание недоступно",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
)
assertEquals(
"модель не скачалась — повторю автоматически",
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
)
}
// ————— скролл ответа —————
@@ -0,0 +1,86 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SttModelProviderTest {
private fun newModelDir(): File {
val dir = Files.createTempDirectory("stt-model-test").toFile()
dir.deleteOnExit()
return dir
}
private fun smallFiles() = listOf(
ModelFile("a.onnx", 4L, "sha-a"),
ModelFile("tokenizer/tok.json", 5L, "sha-b"),
)
private fun write(modelDir: File, relPath: String, size: Int) {
val f = File(modelDir, relPath)
f.parentFile?.mkdirs()
f.writeBytes(ByteArray(size))
}
@Test
fun manifestIsSane() {
assertEquals(6, SttModel.FILES.size)
assertEquals(6, SttModel.FILES.map { it.relPath }.toSet().size, "пути дублируются")
SttModel.FILES.forEach { f ->
assertTrue(f.sha256Hex.length == 64, "некорректный SHA-256: ${f.relPath}")
assertTrue(f.sizeBytes > 0, "нулевой размер: ${f.relPath}")
}
// Иерархия 1:1 с filesDir/models телефона: 3 корневых .onnx + tokenizer/ (3 файла).
assertTrue(SttModel.FILES.any { it.relPath == "conv_frontend.onnx" })
assertTrue(SttModel.FILES.any { it.relPath == "tokenizer/merges.txt" })
}
@Test
fun emptyDirNeedsAllFiles() {
val dir = newModelDir()
val files = smallFiles()
val needed = whichFilesNeeded(dir, files)
assertEquals(files, needed)
assertTrue(!isModelComplete(dir, files))
}
@Test
fun completeDirNeedsNothing() {
val dir = newModelDir()
val files = smallFiles()
write(dir, "a.onnx", 4)
write(dir, "tokenizer/tok.json", 5)
assertEquals(emptyList(), whichFilesNeeded(dir, files))
assertTrue(isModelComplete(dir, files))
}
@Test
fun wrongSizeTreatedAsNeeded() {
val dir = newModelDir()
val files = smallFiles()
write(dir, "a.onnx", 3) // короче ожидаемых 4
write(dir, "tokenizer/tok.json", 5)
val needed = whichFilesNeeded(dir, files)
assertEquals(listOf("a.onnx"), needed.map { it.relPath })
}
@Test
fun modelTotalBytesIsSum() {
assertEquals(9L, modelTotalBytes(smallFiles()))
assertTrue(
modelTotalBytes() in 900_000_000..1_000_000_000,
"сумма байт модели не в ожидаемом порядке: ${modelTotalBytes()}",
)
}
@Test
fun sha256HexOfKnownInput() {
val dir = newModelDir()
val f = File(dir, "known.bin")
f.writeBytes("abc".encodeToByteArray())
assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", sha256Hex(f))
}
}
+39
View File
@@ -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,6 +133,9 @@ 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)
@@ -121,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)}}"""
@@ -150,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
@@ -28,6 +28,7 @@ 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
@@ -235,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")
}
}
}
}
@@ -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()
}
@@ -32,6 +32,9 @@ object MercuryBridge {
private const val TAG = "MercuryBridge"
/** Пакет нашего очкового приложения (applicationId из app-glasses). */
const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses"
private var mercury: RayNeoMercury? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -45,6 +48,10 @@ object MercuryBridge {
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())
@@ -60,7 +67,7 @@ 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 показывали реальные значения.
@@ -122,6 +129,28 @@ object MercuryBridge {
_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}")
}
}
}
}
}
}
}
@@ -208,6 +237,16 @@ object MercuryBridge {
return ok
}
/**
* Запустить приложение на очках по пакету (протокол «app-market», [RayNeoMercury.appMarketLaunch]).
* Очки сами открывают приложение по [pkg]; [extras] — опциональные аргументы (как Intent).
*/
suspend fun launchApp(pkg: String, extras: Map<String, String>? = null): Boolean {
val ok = mercury?.appMarketLaunch(pkg, extras) ?: false
setStatus(if (ok) "запуск приложения $pkg на очках" else "ошибка отправки (нет связи?)")
return ok
}
/** Автоблокировка экрана через N секунд; -1 = никогда. */
suspend fun autoLockTime(time: Int): Boolean {
val ok = mercury?.setAutoLockTime(time) ?: false
@@ -270,6 +309,96 @@ object MercuryBridge {
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)
@@ -167,6 +167,9 @@ class PhoneApp : Application() {
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
private var localLlmDownloadJob: Job? = null
/** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */
private var localLlmAutoRetry = false
private fun initialLocalLlmState(): LocalLlmModelState =
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
@@ -186,11 +189,15 @@ class PhoneApp : Application() {
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
* Повторный вызов во время скачивания — no-op.
* Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп»)
* ждёт 60с и снова пытается, пока файла нет.
*/
fun downloadLocalLlmModel() {
val current = localLlmDownloadJob
if (current != null && current.isActive) return
localLlmAutoRetry = true
localLlmDownloadJob = scope.launch {
while (localLlmAutoRetry && !localModelFile().isFile) {
val target = localModelFile()
_localLlmState.value = LocalLlmModelState.Downloading(0)
try {
@@ -199,17 +206,24 @@ class PhoneApp : Application() {
}
_localLlmState.value = LocalLlmModelState.Ready
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
return@launch
} catch (e: CancellationException) {
_localLlmState.value = LocalLlmModelState.Idle
throw e
} catch (e: Exception) {
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
log("llm", "не удалось скачать модель: ${e.message}")
val msg = e.message ?: e.toString()
_localLlmState.value = LocalLlmModelState.Error(msg)
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
delay(60_000)
}
}
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
}
}
/** Остановить скачивание локальной модели (частичный файл удаляется). */
/** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
fun cancelLocalLlmDownload() {
localLlmAutoRetry = false
localLlmDownloadJob?.cancel()
}
@@ -313,16 +327,25 @@ class PhoneApp : Application() {
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
*/
private val NO_LLM_ANSWER =
"Серверный LLM не настроен (нет API-ключа в local.properties). " +
"Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " +
"либо задайте llm.apiKey и пересоберите."
private suspend fun assistantLoop() {
for (phrase in assistantChannel) {
log("assistant", "получена фраза: $phrase")
val assistant = ensureAssistant()
val answer: String
if (assistant == null) {
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
continue
}
// Серверный LLM без API-ключа: не «пропущена в никуда», а явная
// подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
answer = NO_LLM_ANSWER
persistNoLlm(phrase)
} else {
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
val answer = runCatching {
answer = runCatching {
val active = _activeSessionId.value
if (active != null) {
assistant.processInSession(active, phrase)
@@ -332,6 +355,7 @@ class PhoneApp : Application() {
}
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
}
server.hub.broadcast(ShowText(answer))
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
server.hub.broadcast(chatHistoryMsg())
@@ -339,6 +363,16 @@ class PhoneApp : Application() {
}
}
/**
* Нет работающего ассистента (сервер без ключа): сохраняем и user-фразу, и
* короткую подсказку, чтобы в чате/на очках не было «провала» в истории.
*/
private fun persistNoLlm(phrase: String) {
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: chatDao.createSession("нет LLM")
chatDao.addMessage(id, role = ChatRole.USER, content = phrase)
chatDao.addMessage(id, role = ChatRole.ASSISTANT, content = NO_LLM_ANSWER)
}
/**
* История активной (или последней) сессии для очков: только роли user/assistant,
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
@@ -499,6 +533,13 @@ class PhoneApp : Application() {
scope.launch { assistantLoop() }
// Автодокачка локальной LLM-модели при старте, если файла нет.
// Каскадный автоповтор (60с) до успеха или «стоп» в настройках.
if (!localModelFile().isFile) {
log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})")
downloadLocalLlmModel()
}
val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false)
@@ -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}") }
}
}
@@ -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 с по
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
* &lt; 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] (по клику) одним вызовом
@@ -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
@@ -17,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
@@ -27,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
@@ -47,6 +52,7 @@ 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 }
@@ -86,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>>()
@@ -198,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) {
@@ -207,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}) — каталог без бейджей")
}
}
}
@@ -233,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()
@@ -264,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,
@@ -279,7 +353,7 @@ 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,
@@ -317,6 +391,7 @@ fun CatalogScreen(
}
}
}
SnackbarHost(hostState = snackbar)
}
}
@@ -373,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,
)
}
}
}
@@ -157,7 +157,7 @@ fun ChatScreen() {
Box {
TextButton(onClick = { sessionMenu = true }) {
Text(
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет",
color = PhoneAccent,
fontSize = 12.sp,
)
@@ -165,7 +165,7 @@ fun ChatScreen() {
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s ->
DropdownMenuItem(
text = { Text(s.title, fontSize = 12.sp) },
text = { Text(s.title.ifBlank { "нет" }, fontSize = 12.sp) },
onClick = {
activeId = s.id
app.selectChatSession(s.id)
@@ -198,12 +198,29 @@ fun ChatScreen() {
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
val llmChoice by app.llmChoice.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B"
else "Серверный LLM · llm.binom.pw",
color = PhoneMuted,
fontSize = 10.sp,
)
Spacer(Modifier.width(6.dp))
// Переключить бэкенд прямо в чате (без захода в настройки).
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "→ серверный" else "→ локальный",
color = PhoneAccent,
fontSize = 10.sp,
modifier = Modifier
.clickable {
app.setLlmModelChoice(
if (llmChoice == LlmModelChoice.LOCAL) LlmModelChoice.REMOTE
else LlmModelChoice.LOCAL,
)
}
.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
Spacer(Modifier.height(4.dp))
// Лента сообщений
@@ -37,6 +37,7 @@ import java.util.Locale
enum class Screen {
CATALOG,
DETAILS,
ACTIVITY,
GLASSES,
GLASSES_INFO,
CHAT,
@@ -46,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, "💬", "Чат"),
)
@@ -307,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,
@@ -315,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),
)
}
@@ -78,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()
}
}
@@ -201,11 +201,12 @@ private fun LlmModelSection() {
)
}
Spacer(Modifier.height(8.dp))
if (choice == LlmModelChoice.LOCAL) {
// Индикатор локальной модели — независимо от выбора бэкенда: пока модель
// скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL.
when (val s = llmState) {
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
@@ -225,14 +226,14 @@ private fun LlmModelSection() {
}
Spacer(Modifier.height(4.dp))
Text(
text = "Скачивание модели… ${s.percent}%",
text = "Скачивание локальной модели… ${s.percent}%",
color = PhoneMuted,
fontSize = 10.sp,
)
}
LocalLlmModelState.Ready -> Text(
text = "Модель готова (Gemma 4 E2B, локально)",
text = "Локальная модель готова (Gemma 4 E2B)",
color = PhoneGreen,
fontSize = 11.sp,
)
@@ -247,7 +248,13 @@ private fun LlmModelSection() {
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
}
}
} else {
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
if (
choice == LlmModelChoice.REMOTE &&
llmState !is LocalLlmModelState.Downloading &&
llmState !is LocalLlmModelState.Error
) {
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
}
}
@@ -41,6 +41,7 @@ import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import pw.binom.viewmate.phone.glassesLivePercent
/** Экран 3 — Статус очков + пульт. */
@@ -61,6 +62,7 @@ fun GlassesScreen(
val playing by app.state.playingFlow.collectAsState()
val mode by app.state.modeFlow.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
val llmState by app.localLlmState.collectAsState()
val durationMs = currentItem?.RunTimeTicks?.div(10_000)
var showDownloads by remember { mutableStateOf(false) }
@@ -222,6 +224,18 @@ fun GlassesScreen(
)
}
// авто-скачивание локальной LLM-модели (Gemma 4 E2B)
val llmModelState = llmState
if (llmModelState is LocalLlmModelState.Downloading) {
Text(
text = "скачивание LLM-модели: ${llmModelState.percent}%",
color = PhoneYellow,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -5,6 +5,7 @@ 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
@@ -112,6 +113,66 @@ fun MercuryPanel() {
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { MercuryBridge.startPairing() },
enabled = !state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneAccent,
contentColor = Color(0xFF10251A),
),
) {
Text("🔗 паринг (QR)", fontSize = 12.sp)
}
if (state.pairCode != null) {
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { MercuryBridge.cancelPairing() },
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text("отмена", fontSize = 12.sp)
}
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.pairClassicAuto() } },
enabled = !state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF3A3F47),
contentColor = Color(0xFFD5DBDF),
),
) {
Text("🔗 паринг (вручную)", fontSize = 12.sp)
}
}
val pairCode = state.pairCode
if (pairCode != null) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Паринг: отсканируйте QR картинку камерой очков (она должна смотреть на экран телефона)",
color = PhoneMuted,
fontSize = 11.sp,
)
QrCode(pairCode, side = 280, modifier = Modifier.padding(top = 8.dp))
Spacer(modifier = Modifier.height(6.dp))
Text(
"Код: $pairCode",
color = PhoneText,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.screenOff() } },
@@ -136,6 +197,19 @@ fun MercuryPanel() {
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.launchApp(MercuryBridge.VIEWMATE_GLASSES_PKG) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Запустить приложение", fontSize = 12.sp)
}
}
// ---- Яркость ----
Text(
text = "Яркость: $brightness%",
@@ -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) {
@@ -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"))
}
}
@@ -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): краевая тишина убирается,
+10
View File
@@ -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")
}
@@ -42,6 +42,35 @@ fun defaultHttpClient(): HttpClient = HttpClient {
}
}
/**
* HTTP-клиент для media-mirror: тяжёлые опросы (mirror.list с большим лимитом)
* на сервере считаются заметно дольше, чем обычные запросы, поэтому таймауты
* шире [defaultHttpClient] — иначе живой, но медленный ответ упирается в таймаут.
*/
fun defaultMirrorHttpClient(): HttpClient = HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
)
}
install(HttpTimeout) {
connectTimeoutMillis = 15_000
requestTimeoutMillis = 90_000
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
}
}
level = LogLevel.INFO
}
}
/** Флаг debug-логирования HTTP (по умолчанию включён). */
object HttpClientDebugLogging {
var enabled: Boolean = true
@@ -2,6 +2,8 @@ package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
@@ -19,7 +21,7 @@ import io.ktor.http.contentType
class MirrorClient(
private val baseUrl: String, // https://mirror.binom.pw
private val apiKey: String, // X-API-Key
private val http: HttpClient = defaultHttpClient(),
private val http: HttpClient = defaultMirrorHttpClient(),
) {
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
suspend fun createMirror(itemId: String): MirrorJob {
@@ -42,8 +44,11 @@ class MirrorClient(
return response.body()
}
/** Список зеркал: статус (null — все) + лимит. */
/** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
var last: Exception? = null
repeat(LIST_ATTEMPTS) { attempt ->
try {
val response = http.get("$baseUrl/api/mirror") {
header("X-API-Key", apiKey)
parameter("limit", limit)
@@ -51,6 +56,14 @@ class MirrorClient(
}
response.ensureSuccess("mirror.list")
return response.body()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
last = e
if (attempt < LIST_ATTEMPTS - 1) delay(LIST_RETRY_DELAY_MS)
}
}
throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
}
/** Удаление зеркала (204). */
@@ -69,4 +82,11 @@ class MirrorClient(
response.ensureSuccess("mirror.scan")
return response.body()
}
companion object {
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
private const val LIST_ATTEMPTS = 2
private const val LIST_RETRY_DELAY_MS = 1_500L
}
}
@@ -109,6 +109,16 @@ data class SttAudio(val data: ByteArray) : GlassesToHost {
@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
/**
* Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему
* коллектору и пересылает на сервер вместе со своими лог-записями;
@@ -0,0 +1,171 @@
package pw.binom.viewmate.core.stt
import kotlin.math.sqrt
/**
* Чистые (без Android) преобразования PCM перед офлайн-распознаванием — общие
* для телефона и очков. Телефон накапливает и распознаёт на себе (фолбэк); очки
* распозновывают локально и шлют готовый текст (новый путь). Тело — дословно из
* SttStreamer (app-phone): числа выверены на дампах микрофона очков (×3, 16 кГц),
* НЕ менять без перепроверки.
*/
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
fun s16leToFloat(pcm: ByteArray): FloatArray {
val n = pcm.size / 2
val out = FloatArray(n)
var i = 0
for (j in 0 until n) {
val lo = pcm[i].toInt() and 0xff
val hi = pcm[i + 1].toInt() and 0xff
i += 2
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
out[j] = (s / 32768f).coerceIn(-1f, 1f)
}
return out
}
/**
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
*
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
* Чистая функция (без Android) — покрыта JVM-тестами.
*/
fun trimEdges(
samples: FloatArray,
threshold: Float = 0.01f,
windowSize: Int = 320,
padWindows: Int = 15,
): FloatArray {
if (samples.isEmpty()) return samples
var first = -1
var lastEnd = -1
var idx = 0
while (idx < samples.size) {
val end = minOf(idx + windowSize, samples.size)
var sum = 0.0
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
val rms = sqrt(sum / (end - idx)).toFloat()
if (rms >= threshold) {
if (first == -1) first = idx
lastEnd = end
}
idx = end
}
if (first == -1) return FloatArray(0)
val pad = padWindows * windowSize
val start = maxOf(0, first - pad)
val stop = minOf(samples.size, lastEnd + pad)
return samples.copyOfRange(start, stop)
}
/**
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
* НЕ менять без перепроверки на тех же дампах.
*
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
* каждой стороны (clamp к границам массива). Фраза короче
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
* &lt; 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
*/
fun splitPhrases(
samples: FloatArray,
threshold: Float = 0.005f,
windowSize: Int = 320, // 20 мс @ 16 кГц
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
): List<FloatArray> {
if (samples.isEmpty()) return emptyList()
val winCount = (samples.size + windowSize - 1) / windowSize
val isSound = BooleanArray(winCount)
var anySound = false
for (w in 0 until winCount) {
val s = w * windowSize
val e = minOf(s + windowSize, samples.size)
var sum = 0.0
for (k in s until e) sum += samples[k] * samples[k].toDouble()
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
isSound[w] = true
anySound = true
}
}
if (!anySound) return emptyList()
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
class Phrase(var start: Int, var end: Int)
val phrases = mutableListOf<Phrase>()
var pos = 0
while (pos < winCount) {
var sil = 0
while (pos + sil < winCount && !isSound[pos + sil]) sil++
if (pos + sil >= winCount) break
val start = pos + sil
var end = start
var continuePhrase = true
while (continuePhrase) {
var run = 0
while (end + run < winCount && isSound[end + run]) run++
end += run
if (end >= winCount) { continuePhrase = false; break }
var sil2 = 0
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
if (sil2 >= minSilenceWindows) {
continuePhrase = false
} else {
end += sil2
}
}
phrases.add(Phrase(start, end))
pos = end
}
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
while (phrases.size > 1) {
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
if (idx < 0) break
val other = if (idx == 0) idx + 1 else idx - 1
val lo = minOf(idx, other)
val hi = maxOf(idx, other)
val merged = Phrase(
minOf(phrases[lo].start, phrases[hi].start),
maxOf(phrases[lo].end, phrases[hi].end),
)
phrases.removeAt(hi)
phrases.removeAt(lo)
phrases.add(lo, merged)
}
val pad = padWindows * windowSize
val out = mutableListOf<FloatArray>()
for (p in phrases) {
val s = maxOf(0, p.start * windowSize - pad)
val e = minOf(samples.size, p.end * windowSize + pad)
if (e - s <= maxPhraseSamples) {
out += samples.copyOfRange(s, e)
} else {
val chunk = (maxPhraseSamples / windowSize) * windowSize
var cs = s
while (cs < e) {
val ce = minOf(e, cs + chunk)
out += samples.copyOfRange(cs, ce)
cs = ce
}
}
}
return out
}
@@ -213,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)
}
}