Compare commits
11 Commits
f656f43e10
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e81026b9f5 | |||
| 7db0fc71de | |||
| 8083978849 | |||
| 1b817aa9a6 | |||
| 79a500150d | |||
| f68ea10342 | |||
| 5c86ed00b4 | |||
| 0b3f9ff975 | |||
| 40723fa584 | |||
| 728116662e | |||
| 0cfd92a68f |
@@ -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
|
||||
.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 ещё нет — не могу вернуть микрофон")
|
||||
}
|
||||
}
|
||||
GlassesApp.instance.assistantEngine.onChatHistory(
|
||||
msg.messages
|
||||
.filter { it.role == "user" || it.role == "assistant" }
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun onTempleAction(action: TempleAction) {
|
||||
val g = action.toGesture() ?: run {
|
||||
log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор")
|
||||
return
|
||||
}
|
||||
log("gesture", "жест: ${g}")
|
||||
GlassesApp.instance.layoutManager.dispatch(g)
|
||||
}
|
||||
|
||||
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
|
||||
private fun startAssistantMode() {
|
||||
val app = GlassesApp.instance
|
||||
if (app.listening.value) {
|
||||
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
||||
return
|
||||
}
|
||||
log("stt", "ассистент: слушаю")
|
||||
if (app.movieController.pauseIfPlaying()) {
|
||||
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||
}
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
app.listening.value = true
|
||||
app.thinking.value = false
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
}
|
||||
|
||||
/** Click (кино): плей/пауза — как сегодня. */
|
||||
private fun togglePlayback() {
|
||||
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
||||
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
}
|
||||
}
|
||||
|
||||
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||
private fun sendPhrase() {
|
||||
log("gesture", "Click → отправить фразу")
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.listening.value = false
|
||||
GlassesApp.instance.thinking.value = true
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||
}
|
||||
}
|
||||
|
||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||
private fun startNewPhrase() {
|
||||
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
|
||||
val app = GlassesApp.instance
|
||||
app.assistantText.value = ""
|
||||
app.sttText.value = ""
|
||||
app.thinking.value = false
|
||||
app.answerScrollDelta.value = 0f
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
|
||||
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
|
||||
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
|
||||
*/
|
||||
fun resumeSttListening() {
|
||||
val app = GlassesApp.instance
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||
}
|
||||
|
||||
/**
|
||||
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (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", "экран включён — продолжаю")
|
||||
}
|
||||
|
||||
+134
-49
@@ -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.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
||||
state.thinking -> AssistantMode.THINKING
|
||||
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) {
|
||||
is TempleAction.Click -> GestureDecision.SendPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
|
||||
// «Слушаю» (вершина — 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
|
||||
// Стоп: 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("ожидал тот же экземпляр контекста")
|
||||
}
|
||||
}
|
||||
+183
-80
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,30 +189,41 @@ 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 {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
while (localLlmAutoRetry && !localModelFile().isFile) {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
return@launch
|
||||
} catch (e: CancellationException) {
|
||||
_localLlmState.value = LocalLlmModelState.Idle
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val msg = e.message ?: e.toString()
|
||||
_localLlmState.value = LocalLlmModelState.Error(msg)
|
||||
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
|
||||
delay(60_000)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
|
||||
log("llm", "не удалось скачать модель: ${e.message}")
|
||||
}
|
||||
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
|
||||
}
|
||||
}
|
||||
|
||||
/** Остановить скачивание локальной модели (частичный файл удаляется). */
|
||||
/** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
|
||||
fun cancelLocalLlmDownload() {
|
||||
localLlmAutoRetry = false
|
||||
localLlmDownloadJob?.cancel()
|
||||
}
|
||||
|
||||
@@ -313,25 +327,35 @@ 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
|
||||
}
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
val answer = runCatching {
|
||||
val active = _activeSessionId.value
|
||||
if (active != null) {
|
||||
assistant.processInSession(active, phrase)
|
||||
} else {
|
||||
assistant.process(phrase)
|
||||
// Серверный LLM без API-ключа: не «пропущена в никуда», а явная
|
||||
// подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
|
||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
|
||||
answer = NO_LLM_ANSWER
|
||||
persistNoLlm(phrase)
|
||||
} else {
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
answer = runCatching {
|
||||
val active = _activeSessionId.value
|
||||
if (active != null) {
|
||||
assistant.processInSession(active, phrase)
|
||||
} else {
|
||||
assistant.process(phrase)
|
||||
}
|
||||
}
|
||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
}
|
||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
server.hub.broadcast(ShowText(answer))
|
||||
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||
server.hub.broadcast(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 с по
|
||||
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||
* < 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()
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
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,53 +201,60 @@ private fun LlmModelSection() {
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (choice == LlmModelChoice.LOCAL) {
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Модель готова (Gemma 4 E2B, локально)",
|
||||
color = PhoneGreen,
|
||||
// Индикатор локальной модели — независимо от выбора бэкенда: пока модель
|
||||
// скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL.
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
} else {
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание локальной модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Локальная модель готова (Gemma 4 E2B)",
|
||||
color = PhoneGreen,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
}
|
||||
|
||||
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
|
||||
if (
|
||||
choice == LlmModelChoice.REMOTE &&
|
||||
llmState !is LocalLlmModelState.Downloading &&
|
||||
llmState !is LocalLlmModelState.Error
|
||||
) {
|
||||
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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): краевая тишина убирается,
|
||||
|
||||
@@ -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,15 +44,26 @@ class MirrorClient(
|
||||
return response.body()
|
||||
}
|
||||
|
||||
/** Список зеркал: статус (null — все) + лимит. */
|
||||
/** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
|
||||
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
|
||||
val response = http.get("$baseUrl/api/mirror") {
|
||||
header("X-API-Key", apiKey)
|
||||
parameter("limit", limit)
|
||||
if (status != null) parameter("status", status)
|
||||
var last: Exception? = null
|
||||
repeat(LIST_ATTEMPTS) { attempt ->
|
||||
try {
|
||||
val response = http.get("$baseUrl/api/mirror") {
|
||||
header("X-API-Key", apiKey)
|
||||
parameter("limit", limit)
|
||||
if (status != null) parameter("status", status)
|
||||
}
|
||||
response.ensureSuccess("mirror.list")
|
||||
return response.body()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
last = e
|
||||
if (attempt < LIST_ATTEMPTS - 1) delay(LIST_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
response.ensureSuccess("mirror.list")
|
||||
return response.body()
|
||||
throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
|
||||
}
|
||||
|
||||
/** Удаление зеркала (204). */
|
||||
@@ -69,4 +82,11 @@ class MirrorClient(
|
||||
response.ensureSuccess("mirror.scan")
|
||||
return response.body()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
|
||||
private const val LIST_ATTEMPTS = 2
|
||||
|
||||
private const val LIST_RETRY_DELAY_MS = 1_500L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 с по
|
||||
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||
* < 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user