Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e81026b9f5 | |||
| 7db0fc71de | |||
| 8083978849 | |||
| 1b817aa9a6 |
@@ -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,9 +11,11 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||||
import pw.binom.viewmate.core.log.FileLogSpool
|
import pw.binom.viewmate.core.log.FileLogSpool
|
||||||
import pw.binom.viewmate.core.log.SystemClock
|
import pw.binom.viewmate.core.log.SystemClock
|
||||||
import pw.binom.viewmate.glasses.stt.GlassesAsr
|
import pw.binom.viewmate.glasses.gestures.NavFrame
|
||||||
import pw.binom.viewmate.glasses.stt.GlassesSttSession
|
import pw.binom.viewmate.glasses.stt.GlassesStt
|
||||||
import pw.binom.viewmate.glasses.stt.SttModelDownloader
|
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
|
||||||
|
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
|
||||||
|
import pw.binom.viewmate.glasses.ui.layers.toFrame
|
||||||
|
|
||||||
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||||
data class ChatEntry(val role: String, val text: String)
|
data class ChatEntry(val role: String, val text: String)
|
||||||
@@ -47,24 +49,33 @@ class GlassesApp : Application() {
|
|||||||
lateinit var hostConnection: HostConnection
|
lateinit var hostConnection: HostConnection
|
||||||
private set
|
private set
|
||||||
|
|
||||||
lateinit var movieController: MovieController
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
|
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
|
||||||
lateinit var downloadManager: GlassesDownloadManager
|
lateinit var downloadManager: GlassesDownloadManager
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */
|
/** Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте. */
|
||||||
lateinit var sttModel: SttModelDownloader
|
lateinit var stt: GlassesStt
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */
|
/**
|
||||||
lateinit var asr: GlassesAsr
|
* Персистентный менеджер стека layout-ов (корень [MoveLayout] получает [LayoutContext] и
|
||||||
private set
|
* контекст приложения для своего медиастейта). Единственный владелец стека: слои
|
||||||
|
* открывают/закрывают кадры через [LayoutContext.open]/[LayoutContext.close];
|
||||||
|
* [navStack] — проекция этого стека для оверлея.
|
||||||
|
*/
|
||||||
|
val layoutManager = LayoutManager { ctx -> MoveLayout(ctx, this) }
|
||||||
|
|
||||||
/** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */
|
/** Корневой слой (владелец медиастейта): протокол/lifecycle/эффекты дотягиваются до
|
||||||
lateinit var sttSession: GlassesSttSession
|
* [MovieController] через него ([rootLayer.controller]). */
|
||||||
private set
|
val rootLayer: MoveLayout get() = layoutManager.root as MoveLayout
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Движок ассистента: внятный стейт диалога (фазы/тексты/история) + его жестовые эффекты.
|
||||||
|
* Владелец стейта, живёт всё время приложения; слои [AssistantLayout]/[ListeningLayout]
|
||||||
|
* и оверлей дотягиваются до него, протокол/lifecycle пишут через [assistantEngine].
|
||||||
|
* Зависимости от [GlassesApp] (stt/hostConnection/rootLayer) — лениво, в методах.
|
||||||
|
*/
|
||||||
|
val assistantEngine = AssistantEngine(this)
|
||||||
|
|
||||||
|
|
||||||
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
||||||
@@ -84,39 +95,20 @@ class GlassesApp : Application() {
|
|||||||
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
|
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
|
||||||
val vertical = MutableStateFlow(1f)
|
val vertical = MutableStateFlow(1f)
|
||||||
|
|
||||||
/**
|
// Стейт диалога ассистента (thinking/assistantText/chatHistory/answerScrollDelta + idle-hint
|
||||||
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click,
|
// sttNoSpeech) — в [assistantEngine], владелец стейта ассистента. Микрофон и распознавание
|
||||||
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
|
// (sttText, старт/стоп стт-сессии) владеет слой [ListeningLayout]; «модель не скачалась» —
|
||||||
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
|
// из stt.status. Движок микрофоном не владеет.
|
||||||
*/
|
|
||||||
val listening = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
|
|
||||||
val sttText = MutableStateFlow("")
|
|
||||||
|
|
||||||
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
|
||||||
val assistantText = MutableStateFlow("")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
|
* Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не
|
||||||
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
|
* вытолкнуть) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон, чат на фоне).
|
||||||
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
|
* Тройной клик в кино кладёт «ассистент» + «слушаю»; двойной клик POP-ит верхний кадр:
|
||||||
|
* из «слушаю» → в кадр ассистента (idle, микрофон закрыт, чат не прерван), из idle/
|
||||||
|
* «думаю»/«ответ» → в «фильм». Держится до явного выхода (двойной клик в кадре
|
||||||
|
* ассистента, 30с тишины, потеря фокуса) и сбрасывается в [AssistantEngine.exitMode].
|
||||||
*/
|
*/
|
||||||
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
|
val navStack = MutableStateFlow(listOf(NavFrame.Film))
|
||||||
|
|
||||||
/**
|
|
||||||
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
|
||||||
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
|
||||||
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
|
|
||||||
*/
|
|
||||||
val thinking = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
|
|
||||||
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
|
|
||||||
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
|
|
||||||
*/
|
|
||||||
val answerScrollDelta = MutableStateFlow(0f)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
||||||
@@ -148,12 +140,12 @@ class GlassesApp : Application() {
|
|||||||
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
|
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
|
||||||
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
|
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
|
||||||
MercurySDK.init(this)
|
MercurySDK.init(this)
|
||||||
movieController = MovieController(applicationContext)
|
// Медиадвижок живёт в корневом слое (rootLayer.controller), не в приложении.
|
||||||
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
|
rootLayer.controller.eventLogger = { msg -> logToFile("mc: $msg") }
|
||||||
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
|
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
|
||||||
hostConnection = HostConnection(
|
hostConnection = HostConnection(
|
||||||
fallbackUrl = GlassesConfig.HOST_URL,
|
fallbackUrl = GlassesConfig.HOST_URL,
|
||||||
movieController = movieController,
|
movieController = rootLayer.controller,
|
||||||
downloadManager = downloadManager,
|
downloadManager = downloadManager,
|
||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
)
|
)
|
||||||
@@ -174,20 +166,18 @@ class GlassesApp : Application() {
|
|||||||
pw.binom.viewmate.core.logCollector = logCollector
|
pw.binom.viewmate.core.logCollector = logCollector
|
||||||
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
|
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
|
||||||
|
|
||||||
// Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range).
|
// Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте.
|
||||||
sttModel = SttModelDownloader(File(filesDir, "models"), bgScope)
|
// Модель если отсутствует докачивается фоновой корутиной стта (idempotent, по Range). Куда
|
||||||
sttModel.ensure()
|
// уходит готовый текст (onResult) регистрирует владелец — слой записи (ListeningLayout.onEnter);
|
||||||
|
// приложение результат к mainActivity не шьёт.
|
||||||
|
stt = GlassesStt(filesDir, bgScope)
|
||||||
|
stt.ensure()
|
||||||
|
|
||||||
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
|
// Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager,
|
||||||
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity.
|
// navStack — лишь его зеркало. Начальная проекция (корень = Film).
|
||||||
asr = GlassesAsr(File(filesDir, "models"))
|
layoutManager.onChange = { layouts -> navStack.value = layouts.map { it.toFrame() } }
|
||||||
sttSession = GlassesSttSession(
|
navStack.value = layoutManager.layersBottomUp().map { it.toFrame() }
|
||||||
filesDir = filesDir,
|
log("app", "layout-менеджер: корень=${layoutManager.current.toFrame()}, navStack=[${navStack.value}]")
|
||||||
scope = bgScope,
|
|
||||||
recognizer = { asr.recognize(it) },
|
|
||||||
modelReady = { sttModel.status.value.ready },
|
|
||||||
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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.SetVertical
|
||||||
import pw.binom.viewmate.core.protocol.Shutdown
|
import pw.binom.viewmate.core.protocol.Shutdown
|
||||||
import pw.binom.viewmate.core.protocol.ShowText
|
import pw.binom.viewmate.core.protocol.ShowText
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
|
||||||
import pw.binom.viewmate.core.protocol.SttDone
|
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
|
||||||
import pw.binom.viewmate.core.protocol.Welcome
|
import pw.binom.viewmate.core.protocol.Welcome
|
||||||
import pw.binom.viewmate.core.protocol.protocolJson
|
import pw.binom.viewmate.core.protocol.protocolJson
|
||||||
|
|
||||||
@@ -354,56 +351,18 @@ class HostConnection(
|
|||||||
|
|
||||||
is ShowText -> {
|
is ShowText -> {
|
||||||
addMessage("ShowText: ${msg.text}")
|
addMessage("ShowText: ${msg.text}")
|
||||||
GlassesApp.instance.thinking.value = false
|
GlassesApp.instance.assistantEngine.onShowText(msg.text)
|
||||||
GlassesApp.instance.answerScrollDelta.value = 0f
|
|
||||||
GlassesApp.instance.assistantText.value = msg.text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
||||||
|
|
||||||
is ChatHistoryMsg -> {
|
is ChatHistoryMsg -> {
|
||||||
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
|
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
|
||||||
GlassesApp.instance.chatHistory.value = msg.messages
|
GlassesApp.instance.assistantEngine.onChatHistory(
|
||||||
.filter { it.role == "user" || it.role == "assistant" }
|
msg.messages
|
||||||
.map { ChatEntry(it.role, it.content) }
|
.filter { it.role == "user" || it.role == "assistant" }
|
||||||
}
|
.map { ChatEntry(it.role, it.content) },
|
||||||
|
)
|
||||||
is SttPhrase -> {
|
|
||||||
GlassesApp.instance.sttText.value = msg.full
|
|
||||||
log("stt", "фраза: ${msg.phrase}")
|
|
||||||
}
|
|
||||||
|
|
||||||
is SttDone -> {
|
|
||||||
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
|
|
||||||
GlassesApp.instance.listening.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
is SttCancel -> {
|
|
||||||
when (msg.reason) {
|
|
||||||
SttCancel.REASON_CANCEL -> {
|
|
||||||
// Явная отмена диктовки двойным тапом: очки САМИ уже вернулись
|
|
||||||
// в «слушаю» по жесту (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 ещё нет — не могу вернуть микрофон")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is Shutdown -> {
|
is Shutdown -> {
|
||||||
@@ -413,6 +372,9 @@ class HostConnection(
|
|||||||
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
|
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
|
||||||
android.os.Process.killProcess(android.os.Process.myPid())
|
android.os.Process.killProcess(android.os.Process.myPid())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Неподдерживаемое host-сообщение — молча игнор (в т.ч. legacy phone-STT: очки не ждут).
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,18 +48,14 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||||
import pw.binom.viewmate.core.protocol.SttText
|
|
||||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
|
||||||
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
|
||||||
import pw.binom.viewmate.glasses.gestures.actionName
|
import pw.binom.viewmate.glasses.gestures.actionName
|
||||||
import pw.binom.viewmate.glasses.gestures.assistantMode
|
|
||||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
|
||||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||||
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||||
import pw.binom.viewmate.glasses.ui.Binocular
|
import pw.binom.viewmate.glasses.ui.Binocular
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
||||||
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
|
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
|
||||||
|
import pw.binom.viewmate.glasses.ui.LayoutStackContent
|
||||||
import pw.binom.viewmate.glasses.ui.Side
|
import pw.binom.viewmate.glasses.ui.Side
|
||||||
import pw.binom.viewmate.glasses.ui.px
|
import pw.binom.viewmate.glasses.ui.px
|
||||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||||
@@ -89,10 +85,10 @@ class MainActivity : BaseEventActivity() {
|
|||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
Intent.ACTION_SCREEN_OFF -> {
|
Intent.ACTION_SCREEN_OFF -> {
|
||||||
log("ui", "SCREEN_OFF receiver")
|
log("ui", "SCREEN_OFF receiver")
|
||||||
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
|
||||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||||
logEvent("SCREEN_OFF: pause wasPlaying=$was")
|
logEvent("SCREEN_OFF: pause wasPlaying=$was")
|
||||||
GlassesApp.instance.movieController.ensureWifiLock()
|
GlassesApp.instance.rootLayer.controller.ensureWifiLock()
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
if (was) log("ui", "экран погас (receiver) — пауза")
|
if (was) log("ui", "экран погас (receiver) — пауза")
|
||||||
}
|
}
|
||||||
@@ -101,7 +97,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
|
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
|
||||||
if (resumeAfterScreenOn) {
|
if (resumeAfterScreenOn) {
|
||||||
resumeAfterScreenOn = false
|
resumeAfterScreenOn = false
|
||||||
GlassesApp.instance.movieController.resume()
|
GlassesApp.instance.rootLayer.controller.resume()
|
||||||
sendPositionWithRetries()
|
sendPositionWithRetries()
|
||||||
log("ui", "экран включён (receiver) — продолжаю")
|
log("ui", "экран включён (receiver) — продолжаю")
|
||||||
}
|
}
|
||||||
@@ -119,24 +115,10 @@ class MainActivity : BaseEventActivity() {
|
|||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
Box(
|
// Layout-стек (LIFO): персистентный менеджер (GlassesApp.layoutManager) композитит
|
||||||
modifier = Modifier
|
// все слои снизу вверх (кино → затемнение → чат → слушаю). Триггер рекомпозиции —
|
||||||
.fillMaxSize()
|
// стек менеджера (зеркало navStack, обновляется через ctx.open/close).
|
||||||
.background(Color.Black)
|
LayoutStackContent(manager = GlassesApp.instance.layoutManager)
|
||||||
) {
|
|
||||||
val controller = GlassesApp.instance.movieController
|
|
||||||
val movieActive by controller.movieActive.collectAsState()
|
|
||||||
val title by controller.title.collectAsState()
|
|
||||||
val player = controller.player
|
|
||||||
if (movieActive && player != null) {
|
|
||||||
GlassesMovieScreen(player = player, title = title)
|
|
||||||
} else {
|
|
||||||
ConnectionScreen()
|
|
||||||
}
|
|
||||||
// Оверлей режима общения: поверх любого экрана (затемняет кино),
|
|
||||||
// виден в «слушаю»/«думаю»/«ответ виден».
|
|
||||||
AssistantOverlay()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hideSystemBars()
|
hideSystemBars()
|
||||||
@@ -172,16 +154,16 @@ class MainActivity : BaseEventActivity() {
|
|||||||
val state = dm.getDisplay(displayId).state
|
val state = dm.getDisplay(displayId).state
|
||||||
logEvent("display state=$state")
|
logEvent("display state=$state")
|
||||||
if (state == Display.STATE_OFF) {
|
if (state == Display.STATE_OFF) {
|
||||||
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
|
||||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||||
logEvent("display OFF: pause wasPlaying=$was")
|
logEvent("display OFF: pause wasPlaying=$was")
|
||||||
GlassesApp.instance.movieController.ensureWifiLock()
|
GlassesApp.instance.rootLayer.controller.ensureWifiLock()
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
} else if (state == Display.STATE_ON) {
|
} else if (state == Display.STATE_ON) {
|
||||||
logEvent("display ON: resume flag=$resumeAfterScreenOn")
|
logEvent("display ON: resume flag=$resumeAfterScreenOn")
|
||||||
if (resumeAfterScreenOn) {
|
if (resumeAfterScreenOn) {
|
||||||
resumeAfterScreenOn = false
|
resumeAfterScreenOn = false
|
||||||
GlassesApp.instance.movieController.resume()
|
GlassesApp.instance.rootLayer.controller.resume()
|
||||||
sendPositionWithRetries()
|
sendPositionWithRetries()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,171 +194,23 @@ class MainActivity : BaseEventActivity() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
||||||
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
|
* (debugReceiver, эмулятор). Жест деспачится в верхний слой стека через персистентный
|
||||||
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
|
* [pw.binom.viewmate.glasses.LayoutManager] — каждый слой сам решает, что со своим жестом
|
||||||
|
* делать (MoveLayout / AssistantLayout / ListeningLayout).
|
||||||
*/
|
*/
|
||||||
private fun onTempleAction(action: TempleAction) {
|
private fun onTempleAction(action: TempleAction) {
|
||||||
val app = GlassesApp.instance
|
val g = action.toGesture() ?: run {
|
||||||
val state = AssistantState(
|
log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор")
|
||||||
listening = app.listening.value,
|
return
|
||||||
thinking = app.thinking.value,
|
}
|
||||||
sttText = app.sttText.value,
|
log("gesture", "жест: ${g}")
|
||||||
assistantText = app.assistantText.value,
|
GlassesApp.instance.layoutManager.dispatch(g)
|
||||||
)
|
}
|
||||||
when (val decision = gestureDecision(action, state)) {
|
|
||||||
GestureDecision.Ignore ->
|
|
||||||
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
|
|
||||||
GestureDecision.CallAssistant -> startAssistantMode()
|
|
||||||
GestureDecision.TogglePlayback -> togglePlayback()
|
|
||||||
GestureDecision.SendPhrase -> sendPhrase()
|
|
||||||
GestureDecision.NewPhrase -> startNewPhrase()
|
|
||||||
GestureDecision.CancelDictation -> cancelDictation()
|
|
||||||
GestureDecision.ExitMode -> exitAssistantMode()
|
|
||||||
is GestureDecision.Seek -> seekBy(decision)
|
|
||||||
is GestureDecision.ScrollAnswer -> {
|
|
||||||
val acc = app.answerScrollDelta.value + decision.delta
|
|
||||||
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
|
|
||||||
app.answerScrollDelta.value = acc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
|
// Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]:
|
||||||
private fun startAssistantMode() {
|
// слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout.
|
||||||
val app = GlassesApp.instance
|
// Activity о распознавании не знает: запись самозамкнута в [ListeningLayout] (локальная
|
||||||
if (app.listening.value) {
|
// стт), host-STT-события очки не обрабатывают (молча игнор в HostConnection).
|
||||||
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log("stt", "ассистент: слушаю")
|
|
||||||
if (app.movieController.pauseIfPlaying()) {
|
|
||||||
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
|
||||||
}
|
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
||||||
app.listening.value = true
|
|
||||||
app.thinking.value = false
|
|
||||||
app.sttText.value = ""
|
|
||||||
app.assistantText.value = ""
|
|
||||||
app.answerScrollDelta.value = 0f
|
|
||||||
app.sttSession.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Click (кино): плей/пауза — как сегодня. */
|
|
||||||
private fun togglePlayback() {
|
|
||||||
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
|
||||||
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
|
|
||||||
private fun sendPhrase() {
|
|
||||||
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
|
|
||||||
val app = GlassesApp.instance
|
|
||||||
app.listening.value = false
|
|
||||||
app.thinking.value = true
|
|
||||||
app.sttSession.send()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
app.sttSession.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
|
|
||||||
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
|
|
||||||
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
|
|
||||||
*/
|
|
||||||
fun resumeSttListening() {
|
|
||||||
val app = GlassesApp.instance
|
|
||||||
app.thinking.value = false
|
|
||||||
app.listening.value = true
|
|
||||||
app.sttSession.start()
|
|
||||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся
|
|
||||||
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на
|
|
||||||
* экране (даже если истории диалога ещё нет — отмена не закрывает чат).
|
|
||||||
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]).
|
|
||||||
*/
|
|
||||||
private fun cancelDictation() {
|
|
||||||
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
|
|
||||||
val app = GlassesApp.instance
|
|
||||||
app.sttSession.cancel()
|
|
||||||
app.sttText.value = ""
|
|
||||||
app.thinking.value = false
|
|
||||||
app.listening.value = true
|
|
||||||
app.sttSession.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
|
|
||||||
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
|
|
||||||
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
|
|
||||||
* слушать, пустой после лимита — выход из режима.
|
|
||||||
*/
|
|
||||||
fun onSttResult(text: String, auto: Boolean) {
|
|
||||||
val app = GlassesApp.instance
|
|
||||||
app.sttText.value = text
|
|
||||||
when {
|
|
||||||
text.isNotBlank() -> {
|
|
||||||
app.listening.value = false
|
|
||||||
app.thinking.value = true
|
|
||||||
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон")
|
|
||||||
lifecycleScope.launch {
|
|
||||||
app.hostConnection.sendToHost(SttText(text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
|
|
||||||
else -> {
|
|
||||||
log("stt", "клик без голоса — остаюсь в «слушаю»")
|
|
||||||
app.thinking.value = false
|
|
||||||
app.listening.value = true
|
|
||||||
app.sttSession.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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)
|
|
||||||
GlassesApp.instance.sttSession.stop()
|
|
||||||
val app = GlassesApp.instance
|
|
||||||
app.listening.value = false
|
|
||||||
app.thinking.value = false
|
|
||||||
app.sttText.value = ""
|
|
||||||
app.assistantText.value = ""
|
|
||||||
app.answerScrollDelta.value = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
|
|
||||||
private fun seekBy(seek: GestureDecision.Seek) {
|
|
||||||
log("gesture", "жест: свайп → ${seek.command}")
|
|
||||||
GlassesApp.instance.movieController.applyMediaCommand(
|
|
||||||
MediaCommandMsg(seek.command, seek.seekMs)
|
|
||||||
) {
|
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
|
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
|
||||||
@@ -405,15 +239,15 @@ class MainActivity : BaseEventActivity() {
|
|||||||
log("ui", "onPause")
|
log("ui", "onPause")
|
||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
|
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
|
||||||
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
|
// в лаунчер) режим общения гарантированно закрывается — стек POP-ится до фильма.
|
||||||
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
|
if (app.navStack.value.size > 1) {
|
||||||
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
|
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
|
||||||
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
|
app.assistantEngine.exitMode("onPause — потеря фокуса, выход из режима общения")
|
||||||
}
|
}
|
||||||
val was = app.movieController.pauseIfPlaying()
|
val was = app.rootLayer.controller.pauseIfPlaying()
|
||||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||||
logEvent("onPause: pause wasPlaying=$was")
|
logEvent("onPause: pause wasPlaying=$was")
|
||||||
app.movieController.ensureWifiLock()
|
app.rootLayer.controller.ensureWifiLock()
|
||||||
app.hostConnection.sendPlaybackPositionNow()
|
app.hostConnection.sendPlaybackPositionNow()
|
||||||
if (was) log("ui", "экран погас — пауза")
|
if (was) log("ui", "экран погас — пауза")
|
||||||
}
|
}
|
||||||
@@ -425,7 +259,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
logEvent("onResume: resume flag=$resumeAfterScreenOn")
|
logEvent("onResume: resume flag=$resumeAfterScreenOn")
|
||||||
if (resumeAfterScreenOn) {
|
if (resumeAfterScreenOn) {
|
||||||
resumeAfterScreenOn = false
|
resumeAfterScreenOn = false
|
||||||
GlassesApp.instance.movieController.resume()
|
GlassesApp.instance.rootLayer.controller.resume()
|
||||||
sendPositionWithRetries()
|
sendPositionWithRetries()
|
||||||
log("ui", "экран включён — продолжаю")
|
log("ui", "экран включён — продолжаю")
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-49
@@ -8,34 +8,60 @@ import pw.binom.viewmate.glasses.stt.SttModelStatus
|
|||||||
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||||
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
|
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
|
||||||
*
|
*
|
||||||
* @NONE — кино: ассистент не вызван.
|
* Навигация — ЛIFO-стек [NavFrame]: корень [NavFrame.Film] (двойным кликом не вытолкнуть),
|
||||||
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
|
* сверху [NavFrame.Assistant] (чат), сверху [NavFrame.Listening] (микрофон, чат на фоне).
|
||||||
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
|
* Двойной клик — POP верхнего кадра. Режим выводится из вершины стека + контента кадра
|
||||||
* @ANSWER — ответ виден: listening=false и assistantText непуст.
|
* ассистента:
|
||||||
|
*
|
||||||
|
* @NONE — кино (вершина Film): ассистент не вызван.
|
||||||
|
* @LISTENING — вершина Listening: микрофон открыт, чат виден на фоне.
|
||||||
|
* @THINKING — вершина Assistant, фраза отправлена, ждём ответ (ShowText).
|
||||||
|
* @ANSWER — вершина Assistant, ответ виден (assistantText непуст).
|
||||||
|
* @IDLE — вершина Assistant без «думаю»/ответа: микрофон закрыт, чат на месте.
|
||||||
*/
|
*/
|
||||||
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
|
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, IDLE, }
|
||||||
|
|
||||||
|
/** Кадр стека навигации очков. Снизу вверх: [Film] (корень) → [Assistant] → [Listening]. */
|
||||||
|
enum class NavFrame { Film, Assistant, Listening }
|
||||||
|
|
||||||
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
|
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
|
||||||
data class AssistantState(
|
data class AssistantState(
|
||||||
/** Микрофон открыт (TripleClick → слушаю). */
|
/** Стек навигации, снизу вверх. Внизу всегда [NavFrame.Film] (корень). */
|
||||||
val listening: Boolean = false,
|
val stack: List<NavFrame> = listOf(NavFrame.Film),
|
||||||
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
|
/** «Думаю»: фраза отправлена, ждём ответ (ShowText). Контент кадра ассистента. */
|
||||||
val thinking: Boolean = false,
|
val thinking: Boolean = false,
|
||||||
/** Накопленные фразы (SttPhrase, full). */
|
/** Живой текст записи (локальное распознавание в [ListeningLayout]). Контент кадра «слушаю». */
|
||||||
val sttText: String = "",
|
val sttText: String = "",
|
||||||
/** Ответ ассистента (ShowText). */
|
/** Ответ ассистента (ShowText). Контент кадра ассистента. */
|
||||||
val assistantText: String = "",
|
val assistantText: String = "",
|
||||||
)
|
/** Попытка начать диктовку отклонена: модель не скачалась (микрофон не открыт). */
|
||||||
|
val sttStartBlocked: Boolean = false,
|
||||||
|
/** Последний стоп/отправка не поймала голоса — показать «не расслышал». */
|
||||||
|
val sttNoSpeech: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** Вершина стека — текущий кадр. */
|
||||||
|
val top: NavFrame get() = stack.last()
|
||||||
|
/** Микрофон открыт: вершина — [NavFrame.Listening]. */
|
||||||
|
val listening: Boolean get() = top == NavFrame.Listening
|
||||||
|
/** В кадре ассистента (idle/думаю/ответ/слушаю): вершина Assistant или Listening. */
|
||||||
|
val inAssistant: Boolean get() = top == NavFrame.Assistant || top == NavFrame.Listening
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
|
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
|
||||||
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
|
* он приоритетнее «думаю»; вершина [NavFrame.Listening] всегда побеждает (микрофон открыт —
|
||||||
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
|
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
|
||||||
|
*
|
||||||
|
* Стек: фильм (корень) → ассистент → слушаю. «Ассистент» без «слушаю»/ответа/«думаю»
|
||||||
|
* (вершина [NavFrame.Assistant]) — idle-кадр [IDLE]: микрофон закрыт, чат не прерван.
|
||||||
*/
|
*/
|
||||||
fun assistantMode(state: AssistantState): AssistantMode = when {
|
fun assistantMode(state: AssistantState): AssistantMode = when {
|
||||||
state.listening -> AssistantMode.LISTENING
|
state.top == NavFrame.Listening -> AssistantMode.LISTENING
|
||||||
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
state.top == NavFrame.Assistant -> when {
|
||||||
state.thinking -> AssistantMode.THINKING
|
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
||||||
|
state.thinking -> AssistantMode.THINKING
|
||||||
|
else -> AssistantMode.IDLE
|
||||||
|
}
|
||||||
else -> AssistantMode.NONE
|
else -> AssistantMode.NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +71,9 @@ fun assistantMode(state: AssistantState): AssistantMode = when {
|
|||||||
*
|
*
|
||||||
* | Режим | Клик | Двойной | Тройной | Свайпы |
|
* | Режим | Клик | Двойной | Тройной | Свайпы |
|
||||||
* |------------------|--------------------|-----------|---------|----------------------|
|
* |------------------|--------------------|-----------|---------|----------------------|
|
||||||
* | Кино | плей/пауза | игнор | вызов | ±30с |
|
* | Кино (корень) | плей/пауза | игнор | вызов | ±30с |
|
||||||
* | Слушаю | отправить фразу | отмена | игнор | игнор |
|
* | Слушаю | отправить фразу | стоп → idle | игнор | игнор |
|
||||||
|
* | Ассистент (idle) | открыть запись | выход | запись | игнор |
|
||||||
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
|
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
|
||||||
*/
|
*/
|
||||||
sealed interface GestureDecision {
|
sealed interface GestureDecision {
|
||||||
@@ -66,14 +93,17 @@ sealed interface GestureDecision {
|
|||||||
data object NewPhrase : GestureDecision
|
data object NewPhrase : GestureDecision
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
|
* Двойной в «слушаю» (стоп, POP верхнего кадра): закрыть микрофон, ОСТАТЬСЯ в ассистенте
|
||||||
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
|
* (стек: фильм → ассистент, без «слушаю» сверху) — см. [AssistantState.stopListeningFrame].
|
||||||
* оверлей остаётся на экране даже при пустой истории диалога.
|
* Выхода нет: клик открывает запись снова, двойной — выход из чата. Сторона очков:
|
||||||
* Сторона очков: StopStt(cancel=true) на телефон.
|
* StopStt(cancel=true).
|
||||||
*/
|
*/
|
||||||
data object CancelDictation : GestureDecision
|
data object StopListening : GestureDecision
|
||||||
|
|
||||||
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
/** Клик/тройной в idle-кадре ассистента: снова открыть микрофон (вниз по стеку — «слушаю»). */
|
||||||
|
data object BeginListening : GestureDecision
|
||||||
|
|
||||||
|
/** Двойной в «думаю»/«ответ виден»/idle: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||||
data object ExitMode : GestureDecision
|
data object ExitMode : GestureDecision
|
||||||
|
|
||||||
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
|
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
|
||||||
@@ -87,15 +117,11 @@ sealed interface GestureDecision {
|
|||||||
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
|
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
|
||||||
*
|
*
|
||||||
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
|
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
|
||||||
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
|
|
||||||
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
|
|
||||||
* меняем знак здесь, в одной точке).
|
|
||||||
*/
|
*/
|
||||||
fun gestureDecision(
|
fun gestureDecision(
|
||||||
action: TempleAction,
|
action: TempleAction,
|
||||||
state: AssistantState,
|
state: AssistantState,
|
||||||
seekStepMs: Long = 30_000,
|
seekStepMs: Long = 30_000,
|
||||||
pageDeltaPx: Float = 200f,
|
|
||||||
): GestureDecision = when (val mode = assistantMode(state)) {
|
): GestureDecision = when (val mode = assistantMode(state)) {
|
||||||
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
|
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
|
||||||
AssistantMode.NONE -> when (action) {
|
AssistantMode.NONE -> when (action) {
|
||||||
@@ -106,9 +132,30 @@ fun gestureDecision(
|
|||||||
?: GestureDecision.Ignore
|
?: GestureDecision.Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
AssistantMode.LISTENING -> when (action) {
|
// «Слушаю» (вершина — Listening). Модель не скачалась (стtStartBlocked): микрофон не открыт,
|
||||||
is TempleAction.Click -> GestureDecision.SendPhrase
|
// кадр на месте — Click = повторить попытку, DoubleClick = выход. Иначе: Click — отправить,
|
||||||
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
|
// DoubleClick — стоп (POP «слушаю» → idle).
|
||||||
|
AssistantMode.LISTENING -> when {
|
||||||
|
state.sttStartBlocked -> when (action) {
|
||||||
|
is TempleAction.Click -> GestureDecision.BeginListening
|
||||||
|
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||||
|
else -> GestureDecision.Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> when (action) {
|
||||||
|
is TempleAction.Click -> GestureDecision.SendPhrase
|
||||||
|
// Стоп: POP «слушаю» → кадр ассистента (idle), чат не прерван.
|
||||||
|
is TempleAction.DoubleClick -> GestureDecision.StopListening
|
||||||
|
else -> GestureDecision.Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle-кадр ассистента (стек: фильм → ассистент, без «слушаю»): микрофон закрыт.
|
||||||
|
AssistantMode.IDLE -> when (action) {
|
||||||
|
is TempleAction.Click -> GestureDecision.BeginListening
|
||||||
|
is TempleAction.TripleClick -> GestureDecision.BeginListening
|
||||||
|
// Выход из чата: POP «ассистент» → корень (кино).
|
||||||
|
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||||
else -> GestureDecision.Ignore
|
else -> GestureDecision.Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,44 +166,68 @@ fun gestureDecision(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AssistantMode.ANSWER -> when (action) {
|
AssistantMode.ANSWER -> when (action) {
|
||||||
|
// Плавной и дискретной прокрутки ответа больше нет (решение пользователя): свайпы
|
||||||
|
// игнорируем — из-под ответа не ищем по (приостановленному) фильму и не скроллим текст.
|
||||||
is TempleAction.Click -> GestureDecision.NewPhrase
|
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||||
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||||
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
|
|
||||||
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
|
|
||||||
is TempleAction.SlideContinuous ->
|
|
||||||
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
|
|
||||||
// два строка выше.
|
|
||||||
GestureDecision.ScrollAnswer(action.delta)
|
|
||||||
else -> GestureDecision.Ignore
|
else -> GestureDecision.Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»):
|
* Тройной клик в фильме: на стек кладём кадр ассистента (чат) и сразу кадр «слушаю»
|
||||||
* возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый
|
* (микрофон) — стек [Film, Assistant, Listening]. Чат виден на фоне. Чистая функция.
|
||||||
* текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся
|
|
||||||
* на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true,
|
|
||||||
* т.к. listening=true). Закрытие ассистента — отдельное явное действие:
|
|
||||||
* двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]).
|
|
||||||
* Чистая функция — unit-тесты без Android.
|
|
||||||
*/
|
*/
|
||||||
fun afterCancelDictation(state: AssistantState): AssistantState =
|
fun AssistantState.enterAssistant(): AssistantState =
|
||||||
state.copy(listening = true, thinking = false, sttText = "")
|
copy(
|
||||||
|
stack = stack + NavFrame.Assistant + NavFrame.Listening,
|
||||||
|
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
|
||||||
|
)
|
||||||
|
|
||||||
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
|
/** Клик в idle/«думаю»/«ответ»: кладём кадр «слушаю» поверх кадра ассистента. Чистая функция. */
|
||||||
|
fun AssistantState.beginListeningFrame(): AssistantState =
|
||||||
|
copy(stack = stack + NavFrame.Listening, thinking = false, sttText = "", sttNoSpeech = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стоп-записи ([GestureDecision.StopListening], двойной в «слушаю» / пустой клик): POP кадра
|
||||||
|
* «слушаю» — остаёмся в кадре ассистента (idle), чат НЕ прерван, микрофон закрыт. [noSpeech] —
|
||||||
|
* показать «не расслышал». Возобновить запись — Click/Triple ([GestureDecision.BeginListening]);
|
||||||
|
* выйти — DoubleClick ([GestureDecision.ExitMode]). Чистая функция — unit-тесты без Android.
|
||||||
|
*/
|
||||||
|
fun AssistantState.stopListeningFrame(noSpeech: Boolean): AssistantState =
|
||||||
|
copy(stack = stack.dropLast(1), thinking = false, sttText = "", sttNoSpeech = noSpeech)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выход из чата ([GestureDecision.ExitMode], двойной в кадре ассистента / таймаут тишины):
|
||||||
|
* POP кадра ассистента до корня [NavFrame.Film] (фильм). Сброс контента. Чистая функция.
|
||||||
|
*/
|
||||||
|
fun AssistantState.exitToFilm(): AssistantState =
|
||||||
|
copy(
|
||||||
|
stack = listOf(NavFrame.Film),
|
||||||
|
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Оверлей общения виден, пока в кадре ассистента (idle/думаю/ответ/слушаю) или блок модели. */
|
||||||
fun overlayVisible(state: AssistantState): Boolean =
|
fun overlayVisible(state: AssistantState): Boolean =
|
||||||
state.listening || state.thinking || state.assistantText.isNotBlank()
|
state.inAssistant || state.sttStartBlocked
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Заголовок оверлея (над ответом). null — только ответ.
|
* Заголовок оверлея (над ответом). null — только ответ.
|
||||||
|
* - «модель не скачалась» — попытка начать диктовку отклонена (модель не READY),
|
||||||
|
* микрофон не открыт; ниже живая строка прогресса ([modelStatusLine]);
|
||||||
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
|
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
|
||||||
* listening ещё true — микрофон уже закрыт);
|
* listening ещё true — микрофон уже закрыт);
|
||||||
* - «слушаю...»/фраза — микрофон открыт;
|
* - «слушаю...»/фраза — микрофон открыт;
|
||||||
* - при «ответе виден» — последняя фраза как контекст (или null).
|
* - «не расслышал» — стоп/отправка не поймала голоса (пустой клик);
|
||||||
|
* - кадр ассистента (idle/думаю без ответа/ответ виден) — подсказка жестов
|
||||||
|
* (клик — говорить, двойной — выход); чат прокручивается под заголовком.
|
||||||
*/
|
*/
|
||||||
fun overlayHeader(state: AssistantState): String? = when {
|
fun overlayHeader(state: AssistantState): String? = when {
|
||||||
|
state.sttStartBlocked -> "модель не скачалась"
|
||||||
state.thinking && state.assistantText.isBlank() -> "думаю..."
|
state.thinking && state.assistantText.isBlank() -> "думаю..."
|
||||||
state.listening -> state.sttText.ifBlank { "слушаю..." }
|
state.top == NavFrame.Listening -> state.sttText.ifBlank { "слушаю..." }
|
||||||
|
state.sttNoSpeech -> "не расслышал — клик, чтобы повторить"
|
||||||
|
state.top == NavFrame.Assistant -> "клик — говорить · двойной — выход"
|
||||||
else -> state.sttText.takeIf { it.isNotBlank() }
|
else -> state.sttText.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
|
|||||||
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
|
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
|
||||||
// + свежее значение от телефона); без видео — ничего не выводить.
|
// + свежее значение от телефона); без видео — ничего не выводить.
|
||||||
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
|
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
|
||||||
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState()
|
val movieActive = GlassesApp.instance.rootLayer.controller.movieActive.collectAsState()
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(Color(0x99000000)) // полупрозрачный чёрный
|
.background(Color(0x99000000)) // полупрозрачный чёрный
|
||||||
|
|||||||
@@ -37,5 +37,7 @@ class GlassesAsr(
|
|||||||
|
|
||||||
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
||||||
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
|
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
|
||||||
recognizer.recognize(samples, sampleRate)
|
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)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package pw.binom.viewmate.glasses.stt
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
import kotlin.math.sqrt
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -22,7 +23,7 @@ interface GlassesMic {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
||||||
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
|
* локально (через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||||
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
||||||
*
|
*
|
||||||
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
||||||
@@ -30,18 +31,21 @@ interface GlassesMic {
|
|||||||
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
|
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
|
||||||
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
|
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
|
||||||
* пусто после 30s-лимита (timeout) — автозавершение.
|
* пусто после 30s-лимита (timeout) — автозавершение.
|
||||||
*
|
|
||||||
* @param onResult результат распознавания: text — фактический текст (может быть
|
|
||||||
* пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout).
|
|
||||||
*/
|
*/
|
||||||
class GlassesSttSession(
|
class GlassesSttSession(
|
||||||
private val filesDir: File,
|
private val filesDir: File,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val recognizer: (FloatArray) -> String,
|
private val recognizer: GlassesRecognizer,
|
||||||
private val modelReady: () -> Boolean,
|
|
||||||
private val onResult: (text: String, auto: Boolean) -> Unit,
|
|
||||||
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
|
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 на тишину). */
|
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
|
||||||
private val sessionLimitMs = 30_000L
|
private val sessionLimitMs = 30_000L
|
||||||
|
|
||||||
@@ -59,9 +63,18 @@ class GlassesSttSession(
|
|||||||
|
|
||||||
val isRunning: Boolean get() = running
|
val isRunning: Boolean get() = running
|
||||||
|
|
||||||
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
|
/**
|
||||||
|
* Начать слушать: чистый буфер + микрофон + таймер автозавершения.
|
||||||
|
* Гейт в момент ПОПЫТКИ начать сбор: если механизм ещё не готов ([GlassesRecognizer.isReady]=false),
|
||||||
|
* микрофон НЕ открываем и PCM не накапливаем — сбор не начинается, распознавание
|
||||||
|
* гарантированно не будет пустым «потом». Ошибку показывает UI (sttStartBlocked).
|
||||||
|
*/
|
||||||
fun start() {
|
fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
|
if (!recognizer.isReady()) {
|
||||||
|
log("stt", "STT-сессия очков: механизм не готов — микрофон не открываю, сбор не начинаю")
|
||||||
|
return
|
||||||
|
}
|
||||||
running = true
|
running = true
|
||||||
pcmLock.withLock { pcm = ByteArray(0) }
|
pcmLock.withLock { pcm = ByteArray(0) }
|
||||||
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
||||||
@@ -101,13 +114,36 @@ class GlassesSttSession(
|
|||||||
watchdog?.cancel()
|
watchdog?.cancel()
|
||||||
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
val text = if (audio.isNotEmpty() && modelReady()) {
|
val text = if (audio.isNotEmpty() && recognizer.isReady()) {
|
||||||
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
|
val floats = s16leToFloat(audio)
|
||||||
phrases.joinToString(" ") { recognizer(it) }
|
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 {
|
} else {
|
||||||
|
log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено")
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) { onResult(text, auto) }
|
withContext(Dispatchers.Main) { onResult?.invoke(text, auto) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ class SttModelDownloader(
|
|||||||
val parent = File(modelsDir, f.relPath).parentFile
|
val parent = File(modelsDir, f.relPath).parentFile
|
||||||
parent?.mkdirs()
|
parent?.mkdirs()
|
||||||
val final = File(modelsDir, f.relPath)
|
val final = File(modelsDir, f.relPath)
|
||||||
val part = File(modelsDir, "$f.relPath.part")
|
val part = File(modelsDir, "${f.relPath}.part")
|
||||||
val existing = if (part.exists()) part.length() else 0L
|
val existing = if (part.exists()) part.length() else 0L
|
||||||
|
|
||||||
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
|
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import pw.binom.viewmate.glasses.gestures.overlayHeader
|
|||||||
import pw.binom.viewmate.glasses.gestures.modelStatusLine
|
import pw.binom.viewmate.glasses.gestures.modelStatusLine
|
||||||
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||||
|
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
|
||||||
|
import pw.binom.viewmate.glasses.ui.layers.ListeningLayout
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
|
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
|
||||||
@@ -47,19 +49,34 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset
|
|||||||
* [GlassesApp.answerScrollDelta]).
|
* [GlassesApp.answerScrollDelta]).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AssistantOverlay() {
|
fun AssistantOverlay(engine: AssistantEngine) {
|
||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
val listening by app.listening.collectAsState()
|
// Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine].
|
||||||
val thinking by app.thinking.collectAsState()
|
val navStack by app.navStack.collectAsState()
|
||||||
val sttText by app.sttText.collectAsState()
|
val thinking by engine.thinking.collectAsState()
|
||||||
val assistantText by app.assistantText.collectAsState()
|
// Живой текст распознавания владеет слой записи ([ListeningLayout] — единственный, кто в курсе
|
||||||
val chatHistory by app.chatHistory.collectAsState()
|
// микрофона): читаю из верхнего слоя, не из движка. В локальном режиме он vestigial (заполняется
|
||||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
// на результат и скрывается сразу за POP-ом слоя), поэтому реактивность не критична — значение
|
||||||
|
// перечитывается на каждую рекомпозицию (нав-стек + статусы ассистента выше).
|
||||||
|
val sttText = (app.layoutManager.current as? ListeningLayout)?.sttText?.value.orEmpty()
|
||||||
|
val assistantText by engine.assistantText.collectAsState()
|
||||||
|
val sttNoSpeech by engine.sttNoSpeech.collectAsState()
|
||||||
|
val chatHistory by engine.chatHistory.collectAsState()
|
||||||
|
// «Модель не скачалась» выводится из состояния догрузчика (отдельного логического флага в
|
||||||
|
// движке нет): распознавание локально, поэтому модель не READY = распознавание не готово —
|
||||||
|
// объясняем что происходит (качается / не скачалась).
|
||||||
|
val modelStatus by app.stt.status.collectAsState()
|
||||||
|
val state = AssistantState(
|
||||||
|
stack = navStack,
|
||||||
|
thinking = thinking,
|
||||||
|
sttText = sttText,
|
||||||
|
assistantText = assistantText,
|
||||||
|
sttStartBlocked = !modelStatus.ready,
|
||||||
|
sttNoSpeech = sttNoSpeech,
|
||||||
|
)
|
||||||
if (!overlayVisible(state)) return
|
if (!overlayVisible(state)) return
|
||||||
val modelStatus by app.sttModel.status.collectAsState()
|
// Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ модель не READY («не скачалась»).
|
||||||
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
|
val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null
|
||||||
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
|
|
||||||
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null
|
|
||||||
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||||
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||||
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||||
@@ -74,12 +91,12 @@ fun AssistantOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
|
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
|
||||||
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
// аккумулятор (answerScrollDelta) общий в [engine]. Цель — чистый [scrollOffset].
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
app.answerScrollDelta.collect { pending ->
|
engine.answerScrollDelta.collect { pending ->
|
||||||
if (pending == 0f) return@collect
|
if (pending == 0f) return@collect
|
||||||
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
||||||
app.answerScrollDelta.value = 0f
|
engine.answerScrollDelta.value = 0f
|
||||||
if (target != scrollState.value) {
|
if (target != scrollState.value) {
|
||||||
scrollState.animateScrollTo(target, tween(150))
|
scrollState.animateScrollTo(target, tween(150))
|
||||||
}
|
}
|
||||||
@@ -114,7 +131,9 @@ private fun overlayBox(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black),
|
// Полупрозрачный: затемнённый фильм (DimScrim снизу) просвечивает позади чата —
|
||||||
|
// «чат виден на фоне». Чат сам по себе тёмный, читаемость не падает.
|
||||||
|
.background(Color.Black.copy(alpha = 0.88f)),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -127,7 +146,7 @@ private fun overlayBox(
|
|||||||
) {
|
) {
|
||||||
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
|
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
|
||||||
|
|
||||||
if (state.listening) {
|
if (state.listening && !state.sttStartBlocked) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
|
|||||||
@@ -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("ожидал тот же экземпляр контекста")
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
-80
@@ -18,34 +18,43 @@ import pw.binom.viewmate.core.MediaCommand
|
|||||||
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
* Карта жеста (docs/gestures-and-modes.md) — ЛIFO-стек кадров:
|
||||||
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
|
* [NavFrame.Film] (корень) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон).
|
||||||
|
* Двойной клик POP-ает верхний кадр; корень не вытолкнуть. Плюс свайпы → ±30с в кино,
|
||||||
|
* скролл ответа (дельта/зажим/усечение), заголовок оверлея.
|
||||||
*/
|
*/
|
||||||
class AssistantGesturesTest {
|
class AssistantGesturesTest {
|
||||||
|
|
||||||
private val movie = AssistantState()
|
// Стек-состояния (вершина — текущий кадр). Корень всегда [NavFrame.Film].
|
||||||
private val listening = AssistantState(listening = true)
|
private val movie = AssistantState() // [Film]
|
||||||
private val thinking = AssistantState(thinking = true)
|
private val idle = AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant))
|
||||||
private val answer = AssistantState(assistantText = "ответ")
|
private val listening =
|
||||||
|
AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening))
|
||||||
|
private val thinking = idle.copy(thinking = true)
|
||||||
|
private val answer = idle.copy(assistantText = "ответ")
|
||||||
|
private val blocked = listening.copy(sttStartBlocked = true)
|
||||||
|
|
||||||
// ————— режим по состоянию —————
|
// ————— режим по вершине стека —————
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun movieStateIsNone() {
|
fun movieStateIsNone() {
|
||||||
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
|
/** Вершина Listening всё побеждает — жесты идут по матрице «слушаю». */
|
||||||
@Test
|
@Test
|
||||||
fun listeningWinsOverEverything() {
|
fun listeningTopWins() {
|
||||||
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
|
val s = listening.copy(thinking = true, sttText = "ф", assistantText = "а")
|
||||||
assertEquals(AssistantMode.LISTENING, assistantMode(s))
|
assertEquals(AssistantMode.LISTENING, assistantMode(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
|
/** «Ответ виден»: вершина Assistant, assistantText непуст — даже при thinking. */
|
||||||
@Test
|
@Test
|
||||||
fun answerWinsOverThinking() {
|
fun answerWinsOverThinking() {
|
||||||
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
|
assertEquals(
|
||||||
|
AssistantMode.ANSWER,
|
||||||
|
assistantMode(idle.copy(thinking = true, assistantText = "а")),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -53,7 +62,12 @@ class AssistantGesturesTest {
|
|||||||
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
|
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— кино ( NONE) —————
|
@Test
|
||||||
|
fun idleWhenAssistantTopNoContent() {
|
||||||
|
assertEquals(AssistantMode.IDLE, assistantMode(idle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— кино (вершина Film) —————
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun movieMatrix() {
|
fun movieMatrix() {
|
||||||
@@ -78,12 +92,12 @@ class AssistantGesturesTest {
|
|||||||
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
|
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— слушаю —————
|
// ————— слушаю (вершина Listening) —————
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun listeningMatrix() {
|
fun listeningMatrix() {
|
||||||
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
|
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
|
||||||
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
|
assertEquals(GestureDecision.StopListening, gestureDecision(DoubleClick(), listening))
|
||||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
|
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
|
||||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
|
||||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
|
||||||
@@ -91,7 +105,16 @@ class AssistantGesturesTest {
|
|||||||
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
|
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
|
/** Модель не скачалась: кадр Listening на месте, микрофон закрыт — клик повторить, двойной выход. */
|
||||||
|
@Test
|
||||||
|
fun listeningBlockedMatrix() {
|
||||||
|
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), blocked))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), blocked))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), blocked))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), blocked))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— «думаю» / «ответ» (вершина Assistant) —————
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun thinkingMatrix() {
|
fun thinkingMatrix() {
|
||||||
@@ -102,27 +125,87 @@ class AssistantGesturesTest {
|
|||||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— «ответ виден» —————
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun answerMatrix() {
|
fun answerMatrix() {
|
||||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
|
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
|
||||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
|
||||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
|
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
|
||||||
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
|
// Скролла ответа больше нет (решение пользователя: в диалоге ассистента не скроллим) —
|
||||||
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
|
// все свайпы (дискретные и плавный шлейф) игнорируются.
|
||||||
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), answer))
|
||||||
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), answer))
|
||||||
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(12.5f), answer))
|
||||||
|
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(-7f), answer))
|
||||||
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
|
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— стек-переходы —————
|
||||||
|
|
||||||
|
/** Тройной клик в кино: кладём «ассистент» и сразу «слушаю» — [Film, Assistant, Listening]. */
|
||||||
@Test
|
@Test
|
||||||
fun answerPageDeltaIsParameter() {
|
fun enterAssistantPushesBothFrames() {
|
||||||
assertEquals(
|
val s = movie.enterAssistant()
|
||||||
GestureDecision.ScrollAnswer(80f),
|
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), s.stack)
|
||||||
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
|
assertEquals(AssistantMode.LISTENING, assistantMode(s))
|
||||||
)
|
}
|
||||||
|
|
||||||
|
/** Click в idle/«думаю»/«ответ»: кладём «слушаю» поверх кадра ассистента. */
|
||||||
|
@Test
|
||||||
|
fun beginListeningFramePushesListening() {
|
||||||
|
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), idle.beginListeningFrame().stack)
|
||||||
|
assertEquals(AssistantMode.LISTENING, assistantMode(answer.beginListeningFrame()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DoubleClick в «слушаю» — стоп: POP кадра, остаёмся в ассистенте (idle), чат не прерван. */
|
||||||
|
@Test
|
||||||
|
fun stopListeningStaysInAssistant() {
|
||||||
|
val decision = gestureDecision(DoubleClick(), listening)
|
||||||
|
assertEquals(GestureDecision.StopListening, decision)
|
||||||
|
// POP кадра «слушаю» — кадр ассистента (idle), оверлей остаётся, выхода нет.
|
||||||
|
val after = listening.stopListeningFrame(noSpeech = false)
|
||||||
|
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant), after.stack)
|
||||||
|
assertEquals(AssistantMode.IDLE, assistantMode(after))
|
||||||
|
assertTrue(overlayVisible(after))
|
||||||
|
assertFalse(after.listening)
|
||||||
|
// Idle: Click/Triple — возобновить запись; DoubleClick — POP ассистента (выход к фильму).
|
||||||
|
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), after))
|
||||||
|
assertEquals(GestureDecision.BeginListening, gestureDecision(TripleClick(), after))
|
||||||
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стоп первой записи (пустая история): оверлей НЕ закрывается — остаёмся в idle-кадре. */
|
||||||
|
@Test
|
||||||
|
fun stopFirstRecordingKeepsOverlay() {
|
||||||
|
val stopped = listening.copy(sttNoSpeech = true).stopListeningFrame(noSpeech = true)
|
||||||
|
assertTrue(stopped.assistantText.isBlank())
|
||||||
|
assertFalse(stopped.listening)
|
||||||
|
// Оверлей виден (idle-кадр), хотя ни фразы, ни ответа нет.
|
||||||
|
assertTrue(overlayVisible(stopped))
|
||||||
|
// И только явный выход (POP до фильма) прячет оверлей.
|
||||||
|
val exited = stopped.exitToFilm()
|
||||||
|
assertFalse(overlayVisible(exited))
|
||||||
|
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стоп сбрасывает накопленный текст текущей (останавливаемой) записи. */
|
||||||
|
@Test
|
||||||
|
fun stopListeningClearsCancelledText() {
|
||||||
|
val after = listening.copy(sttText = "слова до отмены").stopListeningFrame(noSpeech = false)
|
||||||
|
assertEquals("", after.sttText)
|
||||||
|
assertFalse(after.listening)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выход: POP всё до корня (фильм), сброс контента. */
|
||||||
|
@Test
|
||||||
|
fun exitToFilmPopsToRoot() {
|
||||||
|
val exited =
|
||||||
|
listening.copy(sttText = "ф", thinking = true, assistantText = "а", sttNoSpeech = true)
|
||||||
|
.exitToFilm()
|
||||||
|
assertEquals(listOf(NavFrame.Film), exited.stack)
|
||||||
|
assertFalse(overlayVisible(exited))
|
||||||
|
assertTrue(exited.assistantText.isBlank())
|
||||||
|
assertFalse(exited.thinking)
|
||||||
|
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
|
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
|
||||||
@@ -131,9 +214,9 @@ class AssistantGesturesTest {
|
|||||||
fun sessionTransitions() {
|
fun sessionTransitions() {
|
||||||
var s = movie
|
var s = movie
|
||||||
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
|
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
|
||||||
s = s.copy(listening = true)
|
s = s.enterAssistant()
|
||||||
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
|
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
|
||||||
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
|
s = s.stopListeningFrame(noSpeech = false).copy(thinking = true) // фраза отправлена, ответ ещё нет
|
||||||
assertEquals(AssistantMode.THINKING, assistantMode(s))
|
assertEquals(AssistantMode.THINKING, assistantMode(s))
|
||||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
|
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
|
||||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||||
@@ -141,77 +224,42 @@ class AssistantGesturesTest {
|
|||||||
assertEquals(AssistantMode.ANSWER, assistantMode(s))
|
assertEquals(AssistantMode.ANSWER, assistantMode(s))
|
||||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||||
// Выход: оверлей невидим, кино снова управляется кликом.
|
// Выход: оверлей невидим, кино снова управляется кликом.
|
||||||
val exited = s.copy(assistantText = "")
|
val exited = s.exitToFilm()
|
||||||
assertFalse(overlayVisible(exited))
|
assertFalse(overlayVisible(exited))
|
||||||
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
|
/** Корень «фильм»: двойным кликом попу нельзя; вход в ассистент — Triple. */
|
||||||
@Test
|
@Test
|
||||||
fun cancelStaysInConversation() {
|
fun filmRootCannotBePopped() {
|
||||||
val decision = gestureDecision(DoubleClick(), listening)
|
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
||||||
assertEquals(GestureDecision.CancelDictation, decision)
|
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
|
||||||
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
|
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
|
||||||
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— оверлей —————
|
// ————— оверлей —————
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun overlayInvisibleInMovie() {
|
fun overlayInvisibleInMovie() {
|
||||||
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
|
// Настоящее кино-состояние: стек [Film], оверлей невидим.
|
||||||
assertFalse(overlayVisible(movie))
|
assertFalse(overlayVisible(movie))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun overlayVisibleInListeningThinkingAndAnswer() {
|
fun overlayVisibleInAssistantFrames() {
|
||||||
assertTrue(overlayVisible(listening))
|
assertTrue(overlayVisible(listening))
|
||||||
assertTrue(overlayVisible(thinking))
|
assertTrue(overlayVisible(thinking))
|
||||||
assertTrue(overlayVisible(answer))
|
assertTrue(overlayVisible(answer))
|
||||||
|
assertTrue(overlayVisible(idle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun thinkingHeaderIsDotDotDot() {
|
fun thinkingHeaderIsDotDotDot() {
|
||||||
assertEquals("думаю...", overlayHeader(thinking))
|
assertEquals("думаю...", overlayHeader(thinking))
|
||||||
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
|
// После отправки фразы: вершина ещё Listening + thinking — тоже «думаю...».
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"думаю...",
|
"думаю...",
|
||||||
overlayHeader(AssistantState(listening = true, thinking = true)),
|
overlayHeader(listening.copy(thinking = true)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,10 +270,37 @@ class AssistantGesturesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun answerHeaderIsLastPhraseOrNull() {
|
fun idleHeaderHintsGestures() {
|
||||||
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
|
assertEquals("клик — говорить · двойной — выход", overlayHeader(idle))
|
||||||
assertNull(overlayHeader(answer))
|
// В ответе подсказка та же — чат прокручивается, жесты те же.
|
||||||
assertNull(overlayHeader(movie))
|
assertEquals("клик — говорить · двойной — выход", overlayHeader(answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустой клик без голоса — idle-кадр с «не расслышал». */
|
||||||
|
@Test
|
||||||
|
fun noSpeechIdleShowsHint() {
|
||||||
|
val noSpeech = idle.copy(sttNoSpeech = true)
|
||||||
|
assertEquals(AssistantMode.IDLE, assistantMode(noSpeech))
|
||||||
|
assertEquals("не расслышал — клик, чтобы повторить", overlayHeader(noSpeech))
|
||||||
|
assertTrue(overlayVisible(noSpeech))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— старт отклонён: модель не скачалась —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun overlayVisibleWhenStartBlocked() {
|
||||||
|
assertTrue(overlayVisible(blocked))
|
||||||
|
assertFalse(overlayVisible(movie))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startBlockedHeaderIsModelNotDownloaded() {
|
||||||
|
// Блок бьёт и обычный «думаю...» — заголовок всегда «модель не скачалась».
|
||||||
|
assertEquals("модель не скачалась", overlayHeader(blocked))
|
||||||
|
assertEquals(
|
||||||
|
"модель не скачалась",
|
||||||
|
overlayHeader(blocked.copy(assistantText = "ответ")),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— строка статуса модели —————
|
// ————— строка статуса модели —————
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ object MercuryBridge {
|
|||||||
|
|
||||||
private const val TAG = "MercuryBridge"
|
private const val TAG = "MercuryBridge"
|
||||||
|
|
||||||
|
/** Пакет нашего очкового приложения (applicationId из app-glasses). */
|
||||||
|
const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses"
|
||||||
|
|
||||||
private var mercury: RayNeoMercury? = null
|
private var mercury: RayNeoMercury? = null
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@@ -234,6 +237,16 @@ object MercuryBridge {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить приложение на очках по пакету (протокол «app-market», [RayNeoMercury.appMarketLaunch]).
|
||||||
|
* Очки сами открывают приложение по [pkg]; [extras] — опциональные аргументы (как Intent).
|
||||||
|
*/
|
||||||
|
suspend fun launchApp(pkg: String, extras: Map<String, String>? = null): Boolean {
|
||||||
|
val ok = mercury?.appMarketLaunch(pkg, extras) ?: false
|
||||||
|
setStatus(if (ok) "запуск приложения $pkg на очках" else "ошибка отправки (нет связи?)")
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
/** Автоблокировка экрана через N секунд; -1 = никогда. */
|
/** Автоблокировка экрана через N секунд; -1 = никогда. */
|
||||||
suspend fun autoLockTime(time: Int): Boolean {
|
suspend fun autoLockTime(time: Int): Boolean {
|
||||||
val ok = mercury?.setAutoLockTime(time) ?: false
|
val ok = mercury?.setAutoLockTime(time) ?: false
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ class PhoneApp : Application() {
|
|||||||
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
|
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
|
||||||
|
|
||||||
private var localLlmDownloadJob: Job? = null
|
private var localLlmDownloadJob: Job? = null
|
||||||
|
/** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */
|
||||||
|
private var localLlmAutoRetry = false
|
||||||
|
|
||||||
private fun initialLocalLlmState(): LocalLlmModelState =
|
private fun initialLocalLlmState(): LocalLlmModelState =
|
||||||
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
|
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
|
||||||
|
|
||||||
@@ -186,30 +189,41 @@ class PhoneApp : Application() {
|
|||||||
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
|
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
|
||||||
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
|
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
|
||||||
* Повторный вызов во время скачивания — no-op.
|
* Повторный вызов во время скачивания — no-op.
|
||||||
|
* Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп»)
|
||||||
|
* ждёт 60с и снова пытается, пока файла нет.
|
||||||
*/
|
*/
|
||||||
fun downloadLocalLlmModel() {
|
fun downloadLocalLlmModel() {
|
||||||
val current = localLlmDownloadJob
|
val current = localLlmDownloadJob
|
||||||
if (current != null && current.isActive) return
|
if (current != null && current.isActive) return
|
||||||
|
localLlmAutoRetry = true
|
||||||
localLlmDownloadJob = scope.launch {
|
localLlmDownloadJob = scope.launch {
|
||||||
val target = localModelFile()
|
while (localLlmAutoRetry && !localModelFile().isFile) {
|
||||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
val target = localModelFile()
|
||||||
try {
|
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||||
GemmaModelDownloader(target).download { percent ->
|
try {
|
||||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
GemmaModelDownloader(target).download { percent ->
|
||||||
|
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||||
|
}
|
||||||
|
_localLlmState.value = LocalLlmModelState.Ready
|
||||||
|
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||||
|
return@launch
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
_localLlmState.value = LocalLlmModelState.Idle
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val msg = e.message ?: e.toString()
|
||||||
|
_localLlmState.value = LocalLlmModelState.Error(msg)
|
||||||
|
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
|
||||||
|
delay(60_000)
|
||||||
}
|
}
|
||||||
_localLlmState.value = LocalLlmModelState.Ready
|
|
||||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
|
|
||||||
log("llm", "не удалось скачать модель: ${e.message}")
|
|
||||||
}
|
}
|
||||||
|
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Остановить скачивание локальной модели (частичный файл удаляется). */
|
/** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
|
||||||
fun cancelLocalLlmDownload() {
|
fun cancelLocalLlmDownload() {
|
||||||
|
localLlmAutoRetry = false
|
||||||
localLlmDownloadJob?.cancel()
|
localLlmDownloadJob?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,25 +327,35 @@ class PhoneApp : Application() {
|
|||||||
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
||||||
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
||||||
*/
|
*/
|
||||||
|
private val NO_LLM_ANSWER =
|
||||||
|
"Серверный LLM не настроен (нет API-ключа в local.properties). " +
|
||||||
|
"Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " +
|
||||||
|
"либо задайте llm.apiKey и пересоберите."
|
||||||
|
|
||||||
private suspend fun assistantLoop() {
|
private suspend fun assistantLoop() {
|
||||||
for (phrase in assistantChannel) {
|
for (phrase in assistantChannel) {
|
||||||
log("assistant", "получена фраза: $phrase")
|
log("assistant", "получена фраза: $phrase")
|
||||||
val assistant = ensureAssistant()
|
val assistant = ensureAssistant()
|
||||||
|
val answer: String
|
||||||
if (assistant == null) {
|
if (assistant == null) {
|
||||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
|
// Серверный LLM без API-ключа: не «пропущена в никуда», а явная
|
||||||
continue
|
// подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
|
||||||
}
|
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
|
||||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
answer = NO_LLM_ANSWER
|
||||||
val answer = runCatching {
|
persistNoLlm(phrase)
|
||||||
val active = _activeSessionId.value
|
} else {
|
||||||
if (active != null) {
|
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||||
assistant.processInSession(active, phrase)
|
answer = runCatching {
|
||||||
} else {
|
val active = _activeSessionId.value
|
||||||
assistant.process(phrase)
|
if (active != null) {
|
||||||
|
assistant.processInSession(active, phrase)
|
||||||
|
} else {
|
||||||
|
assistant.process(phrase)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||||
|
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||||
}
|
}
|
||||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
|
||||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
|
||||||
server.hub.broadcast(ShowText(answer))
|
server.hub.broadcast(ShowText(answer))
|
||||||
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||||
server.hub.broadcast(chatHistoryMsg())
|
server.hub.broadcast(chatHistoryMsg())
|
||||||
@@ -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,
|
* История активной (или последней) сессии для очков: только роли user/assistant,
|
||||||
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
||||||
@@ -499,6 +533,13 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
scope.launch { assistantLoop() }
|
scope.launch { assistantLoop() }
|
||||||
|
|
||||||
|
// Автодокачка локальной LLM-модели при старте, если файла нет.
|
||||||
|
// Каскадный автоповтор (60с) до успеха или «стоп» в настройках.
|
||||||
|
if (!localModelFile().isFile) {
|
||||||
|
log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})")
|
||||||
|
downloadLocalLlmModel()
|
||||||
|
}
|
||||||
|
|
||||||
val lastPositionAt = AtomicLong(0)
|
val lastPositionAt = AtomicLong(0)
|
||||||
val lastWasPlaying = AtomicBoolean(false)
|
val lastWasPlaying = AtomicBoolean(false)
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ fun ChatScreen() {
|
|||||||
Box {
|
Box {
|
||||||
TextButton(onClick = { sessionMenu = true }) {
|
TextButton(onClick = { sessionMenu = true }) {
|
||||||
Text(
|
Text(
|
||||||
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
|
text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет",
|
||||||
color = PhoneAccent,
|
color = PhoneAccent,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
@@ -165,7 +165,7 @@ fun ChatScreen() {
|
|||||||
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
|
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
|
||||||
sessions.forEach { s ->
|
sessions.forEach { s ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(s.title, fontSize = 12.sp) },
|
text = { Text(s.title.ifBlank { "нет" }, fontSize = 12.sp) },
|
||||||
onClick = {
|
onClick = {
|
||||||
activeId = s.id
|
activeId = s.id
|
||||||
app.selectChatSession(s.id)
|
app.selectChatSession(s.id)
|
||||||
@@ -198,12 +198,29 @@ fun ChatScreen() {
|
|||||||
|
|
||||||
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
|
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
|
||||||
val llmChoice by app.llmChoice.collectAsState()
|
val llmChoice by app.llmChoice.collectAsState()
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
|
Text(
|
||||||
color = PhoneMuted,
|
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B"
|
||||||
fontSize = 10.sp,
|
else "Серверный LLM · llm.binom.pw",
|
||||||
)
|
color = PhoneMuted,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
// Переключить бэкенд прямо в чате (без захода в настройки).
|
||||||
|
Text(
|
||||||
|
text = if (llmChoice == LlmModelChoice.LOCAL) "→ серверный" else "→ локальный",
|
||||||
|
color = PhoneAccent,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
app.setLlmModelChoice(
|
||||||
|
if (llmChoice == LlmModelChoice.LOCAL) LlmModelChoice.REMOTE
|
||||||
|
else LlmModelChoice.LOCAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
// Лента сообщений
|
// Лента сообщений
|
||||||
|
|||||||
@@ -201,53 +201,60 @@ private fun LlmModelSection() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
if (choice == LlmModelChoice.LOCAL) {
|
// Индикатор локальной модели — независимо от выбора бэкенда: пока модель
|
||||||
when (val s = llmState) {
|
// скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL.
|
||||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
when (val s = llmState) {
|
||||||
Text(
|
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
Text(
|
||||||
color = PhoneMuted,
|
text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||||
fontSize = 11.sp,
|
color = PhoneMuted,
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
|
||||||
}
|
|
||||||
|
|
||||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
ProgressBar(
|
|
||||||
percent = s.percent,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 8.dp),
|
|
||||||
)
|
|
||||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Скачивание модели… ${s.percent}%",
|
|
||||||
color = PhoneMuted,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalLlmModelState.Ready -> Text(
|
|
||||||
text = "Модель готова (Gemma 4 E2B, локально)",
|
|
||||||
color = PhoneGreen,
|
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "Ошибка скачивания: ${s.message}",
|
|
||||||
color = PhoneRed,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ProgressBar(
|
||||||
|
percent = s.percent,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Скачивание локальной модели… ${s.percent}%",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalLlmModelState.Ready -> Text(
|
||||||
|
text = "Локальная модель готова (Gemma 4 E2B)",
|
||||||
|
color = PhoneGreen,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Ошибка скачивания: ${s.message}",
|
||||||
|
color = PhoneRed,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
|
||||||
|
if (
|
||||||
|
choice == LlmModelChoice.REMOTE &&
|
||||||
|
llmState !is LocalLlmModelState.Downloading &&
|
||||||
|
llmState !is LocalLlmModelState.Error
|
||||||
|
) {
|
||||||
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
|
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import pw.binom.viewmate.core.GlassesMode
|
|||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||||
import pw.binom.viewmate.phone.PhoneApp
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||||
import pw.binom.viewmate.phone.glassesLivePercent
|
import pw.binom.viewmate.phone.glassesLivePercent
|
||||||
|
|
||||||
/** Экран 3 — Статус очков + пульт. */
|
/** Экран 3 — Статус очков + пульт. */
|
||||||
@@ -61,6 +62,7 @@ fun GlassesScreen(
|
|||||||
val playing by app.state.playingFlow.collectAsState()
|
val playing by app.state.playingFlow.collectAsState()
|
||||||
val mode by app.state.modeFlow.collectAsState()
|
val mode by app.state.modeFlow.collectAsState()
|
||||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||||
|
val llmState by app.localLlmState.collectAsState()
|
||||||
val durationMs = currentItem?.RunTimeTicks?.div(10_000)
|
val durationMs = currentItem?.RunTimeTicks?.div(10_000)
|
||||||
|
|
||||||
var showDownloads by remember { mutableStateOf(false) }
|
var showDownloads by remember { mutableStateOf(false) }
|
||||||
@@ -222,6 +224,18 @@ fun GlassesScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// авто-скачивание локальной LLM-модели (Gemma 4 E2B)
|
||||||
|
val llmModelState = llmState
|
||||||
|
if (llmModelState is LocalLlmModelState.Downloading) {
|
||||||
|
Text(
|
||||||
|
text = "скачивание LLM-модели: ${llmModelState.percent}%",
|
||||||
|
color = PhoneYellow,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|||||||
@@ -197,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(
|
||||||
text = "Яркость: $brightness%",
|
text = "Яркость: $brightness%",
|
||||||
|
|||||||
Reference in New Issue
Block a user