diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 7d97fd5..9fe1a04 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -17,6 +17,7 @@ import pw.binom.viewmate.glasses.stt.GlassesSttSession import pw.binom.viewmate.glasses.stt.LocalGlassesRecognizer import pw.binom.viewmate.glasses.stt.SttModelDownloader import pw.binom.viewmate.glasses.ui.layers.AssistantEngine +import pw.binom.viewmate.glasses.ui.layers.ListeningLayout import pw.binom.viewmate.glasses.ui.layers.MoveLayout import pw.binom.viewmate.glasses.ui.layers.toFrame @@ -106,8 +107,10 @@ class GlassesApp : Application() { /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */ val vertical = MutableStateFlow(1f) - // Стейт ассистента (listening/sttText/assistantText/chatHistory/thinking/sttStartBlocked - // + sttNoSpeech/answerScrollDelta ниже) вынесен в [assistantEngine] — владелец стейта. + // Стейт диалога ассистента (thinking/assistantText/chatHistory/answerScrollDelta + idle-hint + // sttNoSpeech) — в [assistantEngine], владелец стейта ассистента. Микрофон и распознавание + // (sttText, старт/стоп стт-сессии) владеет слой [ListeningLayout]; «модель не скачалась» — + // из sttModel.status. Движок микрофоном не владеет. /** * Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не @@ -143,6 +146,38 @@ class GlassesApp : Application() { prefs.edit().putFloat("vertical", v).apply() } + // Хостовые STT-события с телефона. Микрофон и распознавание владеет слой записи + // [ListeningLayout]: маршрутизирую в *верхний слой* (транспорт о слоях не знает — + // сюда заходит из [HostConnection]). Слоя записи нет — событие неактуально, игнор. + // Activity о распознавании не знает. + + /** Пустой клик без голоса (SttCancel reason="user") — верхнему слою («не расслышал»). */ + fun stopSttListening() { + val layer = layoutManager.current as? ListeningLayout ?: run { + log("stt", "стоп-записи без слоя записи — игнор") + return + } + layer.onSttCancelUser() + } + + /** Таймаут тишины 30с с телефона (SttCancel reason="timeout") — верхнему слою. */ + fun onSttTimeout() { + val layer = layoutManager.current as? ListeningLayout ?: run { + log("stt", "стт-таймаут без слоя записи — игнор") + return + } + layer.onSttTimeout() + } + + /** Хостовая накопленная фраза (SttPhrase.full с телефона) — верхнему слою (живой текст). */ + fun onSttPhrase(full: String) { + val layer = layoutManager.current as? ListeningLayout ?: run { + log("stt", "стт-фраза без слоя записи — игнор") + return + } + layer.onSttPhrase(full) + } + override fun onCreate() { super.onCreate() instance = this @@ -180,13 +215,13 @@ class GlassesApp : Application() { sttModel.ensure() // Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике - // (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity. + // (нативка лениво) и 30s-лимит на тишину. Куда уходит готовый текст (onResult) регистрирует + // владелец — слой записи (ListeningLayout.onEnter); приложение результат к mainActivity не шьёт. asr = GlassesAsr(File(filesDir, "models")) sttSession = GlassesSttSession( filesDir = filesDir, scope = bgScope, recognizer = LocalGlassesRecognizer(asr, sttModel), - onResult = { text, auto -> mainActivity?.onSttResult(text, auto) }, ) // Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager, diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 2f8894a..09cbadb 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -369,13 +369,15 @@ class HostConnection( } is SttPhrase -> { - GlassesApp.instance.assistantEngine.onSttPhrase(msg.full) + // Хостовую накопленную фразу отдаю *верхнему слою* (как и SttCancel): + // микрофон/распознавание владеет слой записи, а транспорт о слоях не знает + // (роутинг — в [GlassesApp]). + GlassesApp.instance.onSttPhrase(msg.full) log("stt", "фраза: ${msg.phrase}") } is SttDone -> { log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}") - GlassesApp.instance.assistantEngine.onStopListening() } is SttCancel -> { @@ -391,15 +393,13 @@ class HostConnection( // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из // режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode. log("stt", "тишина 30с — выход из режима общения") - GlassesApp.instance.mainActivity?.onSttTimeout() - ?: log("stt", "mainActivity ещё нет — не могу выйти из режима") + GlassesApp.instance.onSttTimeout() } else -> { // reason="user" — пустой клик без голоса: не выходим из режима, // стоп-записи с остаованием в ассистенте (idle), «не расслышал». log("stt", "отменено — стоп, остаюсь в ассистенте (не расслышал)") - GlassesApp.instance.mainActivity?.stopSttListening() - ?: log("stt", "mainActivity ещё нет — не могу сделать стоп") + GlassesApp.instance.stopSttListening() } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index 3f27037..c5224ee 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -48,7 +48,6 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity import kotlinx.coroutines.launch import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.protocol.MediaCommandMsg -import pw.binom.viewmate.core.protocol.SttText import pw.binom.viewmate.glasses.gestures.actionName import pw.binom.viewmate.glasses.status.StatusPanelCompose import pw.binom.viewmate.glasses.ui.AssistantOverlay @@ -210,33 +209,8 @@ class MainActivity : BaseEventActivity() { // Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]: // слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout. - - /** - * SttCancel(reason="user") с телефона — пустой клик без голоса (старый путь StopStt). - * Стоп-записи с ОСТАВАНИЕМ в ассистенте (idle, «не расслышал»). Реализация — в движке - * общения ([GlassesApp.assistantEngine]). - */ - fun stopSttListening() = GlassesApp.instance.assistantEngine.requestSttStop() - - /** - * Результат локального распознавания ([GlassesApp] → [onSttResult], на главном потоке). - * Не-пустой — POP «слушаю», ждём ответ, шлём [SttText] на телефон; пустой по клику — стоп с - * оставанием в ассистенте; пустой после 30с-лимыта — выход из режима. Состояние/навигация — - * в движке ([GlassesApp.assistantEngine]); отправка [SttText] — здесь (нужен [lifecycleScope]). - */ - fun onSttResult(text: String, auto: Boolean) { - val toSend = GlassesApp.instance.assistantEngine.processSttResult(text, auto) - if (toSend != null) { - val app = GlassesApp.instance - lifecycleScope.launch { app.hostConnection.sendToHost(SttText(toSend)) } - } - } - - /** - * Таймаут тишины 30с с телефона (SttCancel reason="timeout"): выход из режима общения. - * Реализация — в движке общения ([GlassesApp.assistantEngine]). - */ - fun onSttTimeout() = GlassesApp.instance.assistantEngine.handleSttTimeout() + // Хостовые STT-события (SttPhrase/SttCancel) маршрутизирует [GlassesApp] в верхний + // слой — Activity о распознавании не знает. /** * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt index 4c310c3..f8baa9a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt @@ -31,17 +31,21 @@ interface GlassesMic { * на телефон уходит фактический текст (SttText). Пустой результат по клику — * остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером); * пусто после 30s-лимита (timeout) — автозавершение. - * - * @param onResult результат распознавания: text — фактический текст (может быть - * пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout). */ class GlassesSttSession( private val filesDir: File, private val scope: CoroutineScope, private val recognizer: GlassesRecognizer, - private val onResult: (text: String, auto: Boolean) -> Unit, private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) }, ) { + /** + * Обработчик готового текста ([finish] → [onResult] на главном потоке). Сессия лишь + * *порождает* результат — куда он уходит, решает владелец: слой записи + * (ui.layers.ListeningLayout) регистрирует в onEnter, сбрасывает в onExit + * (`onResult = null` — слоя нет → результата некому принять). + */ + var onResult: ((text: String, auto: Boolean) -> Unit)? = null + /** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */ private val sessionLimitMs = 30_000L @@ -139,7 +143,7 @@ class GlassesSttSession( log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено") "" } - withContext(Dispatchers.Main) { onResult(text, auto) } + withContext(Dispatchers.Main) { onResult?.invoke(text, auto) } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt index fa07f14..728bca3 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt @@ -36,6 +36,7 @@ import pw.binom.viewmate.glasses.gestures.modelStatusLine import pw.binom.viewmate.glasses.gestures.overlayVisible import pw.binom.viewmate.glasses.gestures.scrollOffset import pw.binom.viewmate.glasses.ui.layers.AssistantEngine +import pw.binom.viewmate.glasses.ui.layers.ListeningLayout /** * Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт @@ -53,24 +54,28 @@ fun AssistantOverlay(engine: AssistantEngine) { // Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine]. val navStack by app.navStack.collectAsState() val thinking by engine.thinking.collectAsState() - val sttText by engine.sttText.collectAsState() + // Живой текст распознавания владеет слой записи ([ListeningLayout] — единственный, кто в курсе + // микрофона): читаю из верхнего слоя, не из движка. В локальном режиме он vestigial (заполняется + // на результат и скрывается сразу за POP-ом слоя), поэтому реактивность не критична — значение + // перечитывается на каждую рекомпозицию (нав-стек + статусы ассистента выше). + val sttText = (app.layoutManager.current as? ListeningLayout)?.sttText?.value.orEmpty() val assistantText by engine.assistantText.collectAsState() - val sttStartBlocked by engine.sttStartBlocked.collectAsState() val sttNoSpeech by engine.sttNoSpeech.collectAsState() val chatHistory by engine.chatHistory.collectAsState() + // «Модель не скачалась» выводится из состояния догрузчика (отдельного логического флага в + // движке нет): распознавание локально, поэтому модель не READY = распознавание не готово — + // объясняем что происходит (качается / не скачалась). + val modelStatus by app.sttModel.status.collectAsState() val state = AssistantState( stack = navStack, thinking = thinking, sttText = sttText, assistantText = assistantText, - sttStartBlocked = sttStartBlocked, + sttStartBlocked = !modelStatus.ready, sttNoSpeech = sttNoSpeech, ) if (!overlayVisible(state)) return - val modelStatus by app.sttModel.status.collectAsState() - // Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ старт отклонён - // (модель не скачалась, стtStartBlocked): распознавание локально, поэтому модель не - // READY = распознавание не готово — объясняем что происходит (качается / не скачалась). + // Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ модель не READY («не скачалась»). val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null // Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды // (левый/правый глаз), поэтому scrollState и коллекторы должны существовать diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt index aa1be0e..55c5b71 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantEngine.kt @@ -4,60 +4,49 @@ 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.LayoutContext import pw.binom.viewmate.glasses.log import pw.binom.viewmate.glasses.gestures.AssistantMode import pw.binom.viewmate.glasses.gestures.NavFrame /** * Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные - * flows в [GlassesApp]. Слои [AssistantLayout]/[ListeningLayout] получают движок и *сами* - * владеют таблицами жестов, вызывая эффекты здесь; чат-оверлей читает стейт тоже отсюда. + * flows в [GlassesApp]. Держит ТОЛЬКО диалог ассистента с телефоном ([thinking]/[assistantText]/ + * [chatHistory]/[answerScrollDelta]) и его реакции на ввод ([continueTurn]/[stopToIdle]). * - * Зависимости (STT-сессия, модель, протокол, медиа-контроллер, менеджер стека) берёт лениво из - * [GlassesApp] в методах — сам движок строится без побочных эффектов (его стейт — лишь flows), - * поэтому порядок инициализации полей приложения не критичен. + * Микрофон и распознавание движку НЕ ЗНАЕТ: всё стт живёт в [ListeningLayout] (единственный, кто + * в курсе микрофона и распознавания); для движка ввод — просто «текст пришёл» ([continueTurn]) или + * «ввод отменён» ([stopToIdle]). Чат-оверлей читает стейт тоже отсюда. + * + * Зависимости (протокол, менеджер стека) берёт лениво из [GlassesApp] в методах — сам движок + * строится без побочных эффектов (его стейт — лишь flows), порядок инициализации полей не критичен. */ class AssistantEngine(private val app: GlassesApp) { - // ── Стейт ассистента (были размазанными полями в GlassesApp) ────────────────────── + // ── Стейт диалога ассистента (был размазанными полями в GlassesApp) ─────────────── /** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */ val thinking = MutableStateFlow(false) - /** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ - val sttText = MutableStateFlow("") - /** Текст ответа ассистента (ShowText); остаётся до новой сессии. */ val assistantText = MutableStateFlow("") - /** Попытка начать диктовку отклонена: модель Qwen3 не скачалась. */ - val sttStartBlocked = MutableStateFlow(false) - - /** Последний стоп/отправка не поймал голоса — оверлей показывает «не расслышал». */ + /** + * У ассистента «не расслышал»: последний ввод отменили без голоса — оверлей показывает + * «не расслышал, тапни ещё раз» (idle-hint). Это стейт *отображения* ассистента (факт + * отмены несёт [stopToIdle]), не стт-факт — распознанным текстом движок не владеет. + */ val sttNoSpeech = MutableStateFlow(false) - /** Идёт распознавание (TripleClick открыл, завершает Click / 30s-лимит). */ - val listening = MutableStateFlow(false) - /** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */ val chatHistory = MutableStateFlow>(emptyList()) /** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */ val answerScrollDelta = MutableStateFlow(0f) - /** - * Кэлбэки ВЫЗЫВШИГО (слоя ассистента): [listeningSuccess] — куда слой записи возвращает - * распознанный текст, [listeningCancel] — факт отмены ввода. Привязываются - * [ListeningLayout.onEnter] через [bindListening], отцепляются в [ListeningLayout.onExit]; - * null — записи нет / POP-нута. - */ - private var listeningSuccess: ((String) -> Unit)? = null - private var listeningCancel: (() -> Unit)? = null - /** Скоуп корутин, которые запускают вызывающие слои (явное ожидание результата записи). */ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -73,54 +62,14 @@ class AssistantEngine(private val app: GlassesApp) { // ── Эффекты (перенесены из GlassesGestureEffects) ──────────────────────────────── /** - * TripleClick в кино: пауза фильма + открыть кадр «ассистент» ([AssistantLayout]) и - * сразу «слушание» ([ListeningLayout]). Фильм остаётся на паузе, аудиосинхр улетает на - * телефон (продолжит догнанку, не перемотает). + * Кадр ассистента открыт ([AssistantLayout.onEnter]) — чистый старт сессии: стираем прошлый + * ответ / «не расслышал» / дельту скролла. Паузу фильма и синх позиций делает ВЛАДЕЛЕЦ (слой + * кино), а не движок: здесь чистится только *свой* стейт. */ - fun enterAssistant() { - if (app.layoutManager.depth > 1) { - log("gesture", "TripleClick проигнорирован: уже в ассистенте") - return - } - log("stt", "ассистент: слушаю (стек: фильм → ассистент → слушаю)") - if (app.rootLayer.controller.pauseIfPlaying()) { - log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода") - } - app.hostConnection.sendPlaybackPositionNow() + fun beginSession() { sttNoSpeech.value = false assistantText.value = "" answerScrollDelta.value = 0f - // Кадр ассистента сам открывает слой записи (в своём onEnter), передавая кэлбэки onSuccess/onCancel. - app.layoutManager.open { ctx -> AssistantLayout(ctx, this) } - } - - /** - * Слой записи открыт микрофон ([ListeningLayout.onEnter]): открыть микрофон только если модель - * готова. Модель Qwen3 ещё не скачалась — микрофон не открываем: показываем «модель не - * скачалась» ([sttStartBlocked]), распознавание не готово. Здесь стек НЕ трогаем — слой уже - * открыт (его открыл [enterAssistant] / кадр ассистента). - */ - fun beginMic() { - sttNoSpeech.value = false - sttText.value = "" - if (!app.sttModel.status.value.ready) { - listening.value = false - thinking.value = true - sttStartBlocked.value = true - log("stt", "старт диктовки отклонён: модель Qwen3 не скачалась — микрофон не открываю, показываю ошибку") - return - } - sttStartBlocked.value = false - thinking.value = false - listening.value = true - app.sttSession.start() - } - - /** Слой записи закрыт микрофон ([ListeningLayout.onExit]): закрыть микрофон, гасить «слушаю». */ - fun endMic() { - app.sttSession.stop() - listening.value = false - sttText.value = "" } /** Логика «думает ассистент или нет»: сотереть показанный ответ и дельту скролла перед новой фразой. */ @@ -130,105 +79,37 @@ class AssistantEngine(private val app: GlassesApp) { } /** - * Слой записи открыт ([ListeningLayout.onEnter]): привязать кэлбэки вызвавшего, чтобы сюда - * доставлять распознанный текст и факт отмены ввода. + * Ввод принят ([ListeningLayout.onSuccess] → вызывающий [AssistantLayout]): жду ответ + * ([thinking]). Слой записи сам себя POP-нул (ctx.close), его onExit закрыл микрофон — + * движок микрофоном не владеет, стек здесь не трогаем. «Продолжить ход» диалога с + * телефоном = отправить распознанный ввод на LLM ([SttText]) и ожидать ответ. */ - fun bindListening(onSuccess: (String) -> Unit, onCancel: () -> Unit) { - listeningSuccess = onSuccess - listeningCancel = onCancel - } - - /** Слой записи закрыт ([ListeningLayout.onExit]): отцепить кэлбэки (дальше результатов нет). */ - fun unbindListening() { - listeningSuccess = null - listeningCancel = null - } - - /** - * Доставить распознанный текст вызвавшему ассистенту ([ListeningLayout.onSuccess]). Если слой - * записи уже POP-нут (кэлбэк отцеплен) — эффект выполняю сам, без ассистента. - */ - private fun deliverSuccess(text: String) { - val cb = listeningSuccess - if (cb != null) cb.invoke(text) else continueTurn(text) - } - - /** Доставить факт отмены вызвавшему ассистенту. Если слой записи уже POP-нут — [stopToIdle] выполняю сам. */ - private fun deliverCancel() { - val cb = listeningCancel - if (cb != null) cb.invoke() else stopToIdle("отмена ввода → стоп, остаюсь в ассистенте", noSpeech = true) - } - - /** Ассистент получил фразу: жду ответ ([thinking]), POP записи (её onExit закроет микрофон). */ - fun continueTurn(text: String) { - log("stt", "ассистент: жду ответ на фразу '${text.take(40)}'") - if (app.layoutManager.current is ListeningLayout) app.layoutManager.close() // onExit → endMic + suspend fun continueTurn(text: String) { + log("stt", "ассистент: фраза «${text.take(40)}» → на телефон (LLM), жду ответ") + app.hostConnection.sendToHost(SttText(text)) thinking.value = true } - /** Клик в «слушаю»: распознать скопленный микрофон локально Qwen3-ом и отправить на телефон. */ - fun sendPhrase() { - log("gesture", "Click → отправить фразу (распознавание локально на очках)") - thinking.value = true - app.sttSession.send() - } - /** - * Стоп-записи с ОСТАВАНИЕМ в кадре ассистента: POP [ListeningLayout] (idle) — его onExit - * закрывает микрофон ([endMic]). Микрофон закрывается ПОД-слоем, а не здесь. + * Ввод отменён ([ListeningLayout.onCancel] → вызывающий [AssistantLayout]): стоп, остаюсь в + * ассистенте (idle). [sttNoSpeech] — idle-hint «не расслышал» (факт отмены, не стт-текст). */ fun stopToIdle(reason: String, noSpeech: Boolean) { log("stt", reason) - if (app.layoutManager.current is ListeningLayout) app.layoutManager.close() // onExit → endMic thinking.value = false sttNoSpeech.value = noSpeech } - /** Выход из режима в корень: closeToRoot (POP [ListeningLayout] → onExit → [endMic]), чистим стейт. */ + /** Выход из режима в корень: closeToRoot (слои POP-нуты, их onExit сам закроет своё), чистим стейт. */ fun exitMode(reason: String) { log("stt", reason) - app.layoutManager.closeToRoot() // POP Listening → onExit → endMic (стоп микрофона) + app.layoutManager.closeToRoot() thinking.value = false - sttStartBlocked.value = false sttNoSpeech.value = false - sttText.value = "" assistantText.value = "" answerScrollDelta.value = 0f } - /** - * Результат локального распознавания (на очках): не пусто → отправить на телефон и ждать - * ответ; пустой авто-результат (тишина 30с) → выход из режима; пустой клик без голоса → - * стоп с остаованием в ассистенте. Возвращает текст для SttText (null — не отправлять). - */ - fun processSttResult(text: String, auto: Boolean): String? { - sttText.value = text - return when { - text.isNotBlank() -> { - log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → ассистенту") - deliverSuccess(text) // → ассистент: continueTurn (жду ответ) - text - } - auto -> { - exitMode("тишина 30с (очки) — выход из режима") - null - } - else -> { - deliverCancel() // → ассистент: stopToIdle (не расслышал) - null - } - } - } - - /** Host-driven: явная отмена диктовки (user) — стоп, остаёмся в ассистенте (не расслышал). */ - fun requestSttStop() { - log("stt", "SttCancel(user) → стоп, остаюсь в ассистенте (не расслышал)") - deliverCancel() // → ассистент: stopToIdle - } - - /** Host-driven: авто-отмена по тишине 30s — выход из режима. */ - fun handleSttTimeout() = exitMode("СттCancel(timeout) — тишина 30с, выход из режима") - // ── Host-driven записи стейта (вместо прямого писания flows в GlassesApp) ────────── /** ShowText: ответ ассистента виден, «думаю» сброшено. */ @@ -242,16 +123,6 @@ class AssistantEngine(private val app: GlassesApp) { fun onChatHistory(messages: List) { chatHistory.value = messages } - - /** SttPhrase: накопленный текст распознавания (для оверлея). */ - fun onSttPhrase(full: String) { - sttText.value = full - } - - /** SttDone: распознавание завершено, индикатор записи гаснет. */ - fun onStopListening() { - listening.value = false - } } /** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */ diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt index cedf9db..f328ee4 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/AssistantLayout.kt @@ -1,12 +1,15 @@ package pw.binom.viewmate.glasses.ui.layers import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.StateFlow 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.GlassesSttSession +import pw.binom.viewmate.glasses.stt.SttModelStatus import pw.binom.viewmate.glasses.ui.AssistantOverlay import kotlin.coroutines.resume @@ -23,6 +26,8 @@ import kotlin.coroutines.resume class AssistantLayout( private val ctx: LayoutContext, private val engine: AssistantEngine, + private val sttSession: GlassesSttSession, + private val modelStatus: StateFlow, ) : Layout { @Composable @@ -33,6 +38,11 @@ class AssistantLayout( @Composable override fun drawStatusPanel() = Unit + /** Кадр ассистента открыт: чистый старт сессии — стираем прошлый ответ / «не расслышал» / дельту скролла. */ + override fun onEnter() { + engine.beginSession() + } + /** * Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк); * логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ). @@ -57,28 +67,17 @@ class AssistantLayout( /** * Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания. - * Возвращает распознанный текст ([onSuccess]) или null ([onCancel], отмена) — слой записи - * завершает ожидание этими кэлбэками (текст доставляет движок, отмену слой вызовал сам). + * Возвращает распознанный текст ([onSuccess]) или null ([onCancel], любая отмена) — + * слой записи завершает ожидание этими кэлбэками. */ - private suspend fun listen() = suspendCancellableCoroutine { context -> - ctx.open { c -> - context.invokeOnCancellation { - c.close() - } - ListeningLayout(c, engine, onSuccess = { text -> - context.resume(text) - }, onCancel = { - context.resume(null) - }) - } - } + private suspend fun listen() = ListeningLayout.listen(ctx, sttSession, modelStatus) /** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */ private fun openListening() { engine.scope.launch { val text = listen() if (text != null) { - engine.continueTurn(text) // распознали фразу → жду ответ (thinking, POP записи) + engine.continueTurn(text) // распознали фразу → жду ответ (thinking); слой записи сам себя POP-нул } else { engine.stopToIdle( "отмена ввода → остаюсь в ассистенте", diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt index e1e6cbd..bd0709a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/ListeningLayout.kt @@ -1,71 +1,163 @@ package pw.binom.viewmate.glasses.ui.layers -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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.GlassesSttSession +import pw.binom.viewmate.glasses.stt.SttModelStatus +import kotlin.coroutines.resume /** - * Под-слой «слушаю» — слой ЗАПИСИ: он *занимается* микрофоном. Пустой, прозрачный поверх кадра - * ассистента (визуал индикатора/фразы рисует [AssistantLayout] под ним, чат остаётся на фоне). + * Слой записи — ЕДИНСТВЕННЫЙ, кто знает про микрофон и распознавание. Он сам открывает + * микрофон ([GlassesSttSession.start] в [onEnter]), закрывает ([onExit]) и сам принимает + * стт-решение: клик распознаёт накопленное ([GlassesSttSession.send]), результат/события + * (локальный [onSttResult], хостовые [onSttPhrase]/[onSttCancelUser]/[onSttTimeout]) слой + * переваривает сам и сообщает вызывающему факт исхода кэлбэками [onSuccess]/[onCancel]. * - * Микрофон открывается/закрывается САМИМ этим слоем: [onEnter] → [AssistantEngine.beginMic] - * (открыть только если модель готова), [onExit] → [AssistantEngine.endMic] (закрыть). - * - * Слой не «знает», что делать с результатом — он возвращает его ВЫЗЫВШИМУ (слою ассистента) двумя - * кэлбэками, которые тот передал при вызове: [onSuccess] (сразу текст распознанной фразы) и - * [onCancel] (просто отмена ввода). Распознанный текст движок доставляет сюда через - * [AssistantEngine.bindListening] (привязан в [onEnter], отцепляется в [onExit]); кэки-жесты слой - * вызывает [onCancel] сам. Если модель не скачалась (sttStartBlocked) — клик пере-начинает попытку, - * двойной клик выходит из режима. + * Для всего остального (кино, чат, ассистент) «текст откуда-то берётся» — никто кроме этого + * слоя не в курсе микрофона и распознавания. Зависимости — конкретные стт-сущности + * ([sttSession] и состояние готовности модели [modelStatus]), а не весь [GlassesApp]; + * исхода двух видов: успех — [onSuccess] с текстом, ЛЮБАЯ отмена (двойной тап, тишина, + * модель не готова) — [onCancel]; выхода из режима слой не делает — это уже эффект вызывающего. */ class ListeningLayout( private val ctx: LayoutContext, - private val engine: AssistantEngine, + private val sttSession: GlassesSttSession, + private val modelStatus: StateFlow, private val onSuccess: (String) -> Unit, private val onCancel: () -> Unit, ) : Layout { - @Composable - override fun drawStage() { - Box(modifier = Modifier.fillMaxSize()) + companion object { + suspend fun listen( + ctx: LayoutContext, + sttSession: GlassesSttSession, + modelStatus: StateFlow, + ) = suspendCancellableCoroutine { context -> + ctx.open { c -> + context.invokeOnCancellation { + c.close() + } + ListeningLayout(c, sttSession, modelStatus, onSuccess = { text -> + context.resume(text) + }, onCancel = { + context.resume(null) + }) + } + } } + /** Накопленный распознанный текст — живой стейт слоя (оверлей читает через вершину стека). */ + val sttText = MutableStateFlow("") + + private val modelReady: Boolean get() = modelStatus.value.ready + + /** Прозрачный слой: сам ничего не рисует — оверлей ассистента (поверх) показывает индикатор и фразу. */ + @Composable + override fun drawStage() = Unit + @Composable override fun drawStatusPanel() = Unit - /** Слой записи открыт: привязать кэлбэки вызвавшего к движку + открыть микрофон (если модель готова). */ + /** Слой открыт: открыть микрофон (само гейтится готовностью модели: не ready → старт молчит). */ override fun onEnter() { - engine.bindListening(onSuccess, onCancel) - engine.beginMic() + sttText.value = "" + // Регистрирую себя обработчиком готового текста: слой сам заводит свой результат, + // сессия лишь порождает текст — куда он идёт, знает только владелец (этот слой). + sttSession.onResult = { text, auto -> onSttResult(text, auto) } + sttSession.start() + log("stt", "слои: вхожу в режим записи, стартую сессию + микрофон") } - /** - * Слой записи закрыт: отцепить кэлбэки + закрыть микрофон. [onCancel] — страховочный вызов: - * закрытие без успешного текста (выход из режима, стоп) сообщает вызывающему об отмене. Для - * вызывающего [onCancel] идемпотентен (deferred уже завершён — повтор игнорируется). - */ + /** Слой закрыт: микрофон закрыть + безопасная отмена ожидания (resume однократен, повтор — no-op). */ override fun onExit() { - engine.endMic() - engine.unbindListening() + sttSession.onResult = null + sttSession.stop() onCancel() } - /** Таблица жестов под-состояния (top == Listening): этот слой *занимается* микрофоном. */ + /** + * Жесты слоя: клик — распознать накопленное (модель не ready — повторить попытку старта); + * двойной — отмена (стоп, остаюсь в ассистенте) или выход (модель не скачалась). Остальное — игнор. + */ override fun gesture(g: Gesture) { when (g) { Gesture.Click -> - if (engine.sttStartBlocked.value) engine.beginMic() else engine.sendPhrase() - Gesture.DoubleClick -> - if (engine.sttStartBlocked.value) { - engine.exitMode("DoubleClick → выход (модель не скачалась)") // onExit → onCancel + if (modelReady) { + log("stt", "клик → распознаю накопленное") + sttSession.send() } else { - onCancel() // → вызывающий: stopToIdle (останемся в ассистенте) + log("stt", "клик: модель не готова — повторю старт записи") + sttSession.start() } - Gesture.TripleClick, Gesture.SwipeForward, Gesture.SwipeBackward -> Unit + + Gesture.DoubleClick -> + if (modelReady) { + log("stt", "двойной → отмена (стоп, остаюсь в ассистенте)") + onCancel() + ctx.close() + } else { + log("stt", "двойной: модель не скачалась → отмена, остаюсь в ассистенте") + onCancel() + ctx.close() + } + + Gesture.TripleClick, + Gesture.SwipeForward, + Gesture.SwipeBackward -> Unit } } + + /** + * Обработчик результата ЛОКАЛЬНОГО распознавания. Слой сам заводит свой результат: + * регистрируется на [GlassesSttSession.onResult] в [onEnter], сбрасывает в [onExit] — + * извне (телефон/LLM-путь) [onSttResult] не вызывают. Слой сам решает исход: + * - текст не пуст → успех ([onSuccess]) + POP слоя; + * - пусто + авто (тишина 30с) → отмена ([onCancel]) + POP слоя; + * - пусто + клик (без голоса) → отмена ([onCancel]) + POP слоя (не расслышал). + * Текст дальше «уходит от куда-то» (в LLM на телефон) — слой это не видит. + */ + 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() + } + } + } + + /** Хостовая накопленная фраза (SttPhrase.full): обновить живой текст слоя. */ + fun onSttPhrase(full: String) { + sttText.value = full + } + + /** Хостовая отмена (SttCancel, reason="user"): отмена, остаюсь в ассистенте (POP слоя). */ + fun onSttCancelUser() { + log("stt", "хост: отмена (user) → стоп, остаюсь в ассистенте (не расслышал)") + onCancel() + ctx.close() + } + + /** Хостовый таймаут (SttCancel, reason="timeout"): отмена, остаюсь в ассистенте (POP слоя). */ + fun onSttTimeout() { + log("stt", "хост: тишина 30с → отмена, остаюсь в ассистенте") + onCancel() + ctx.close() + } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt index aea232f..6bb01b4 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/layers/MoveLayout.kt @@ -49,7 +49,7 @@ class MoveLayout( when (g) { Gesture.Click -> togglePlayback() Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore) - Gesture.TripleClick -> GlassesApp.instance.assistantEngine.enterAssistant() + Gesture.TripleClick -> callAssistant() Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000) Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000) } @@ -70,4 +70,21 @@ class MoveLayout( 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.sttSession, app.sttModel.status) + } + } }