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 9fe1a04..03cb95f 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 @@ -12,10 +12,7 @@ import pw.binom.viewmate.core.log.BatchingLogCollector import pw.binom.viewmate.core.log.FileLogSpool import pw.binom.viewmate.core.log.SystemClock import pw.binom.viewmate.glasses.gestures.NavFrame -import pw.binom.viewmate.glasses.stt.GlassesAsr -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.stt.GlassesStt import pw.binom.viewmate.glasses.ui.layers.AssistantEngine import pw.binom.viewmate.glasses.ui.layers.ListeningLayout import pw.binom.viewmate.glasses.ui.layers.MoveLayout @@ -57,16 +54,8 @@ class GlassesApp : Application() { lateinit var downloadManager: GlassesDownloadManager private set - /** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */ - lateinit var sttModel: SttModelDownloader - private set - - /** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */ - lateinit var asr: GlassesAsr - private set - - /** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */ - lateinit var sttSession: GlassesSttSession + /** Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте. */ + lateinit var stt: GlassesStt private set /** @@ -85,7 +74,7 @@ class GlassesApp : Application() { * Движок ассистента: внятный стейт диалога (фазы/тексты/история) + его жестовые эффекты. * Владелец стейта, живёт всё время приложения; слои [AssistantLayout]/[ListeningLayout] * и оверлей дотягиваются до него, протокол/lifecycle пишут через [assistantEngine]. - * Зависимости от [GlassesApp] (sttSession/sttModel/hostConnection/rootLayer) — лениво, в методах. + * Зависимости от [GlassesApp] (stt/hostConnection/rootLayer) — лениво, в методах. */ val assistantEngine = AssistantEngine(this) @@ -110,7 +99,7 @@ class GlassesApp : Application() { // Стейт диалога ассистента (thinking/assistantText/chatHistory/answerScrollDelta + idle-hint // sttNoSpeech) — в [assistantEngine], владелец стейта ассистента. Микрофон и распознавание // (sttText, старт/стоп стт-сессии) владеет слой [ListeningLayout]; «модель не скачалась» — - // из sttModel.status. Движок микрофоном не владеет. + // из stt.status. Движок микрофоном не владеет. /** * Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не @@ -210,19 +199,12 @@ class GlassesApp : Application() { pw.binom.viewmate.core.logCollector = logCollector log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)") - // Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range). - sttModel = SttModelDownloader(File(filesDir, "models"), bgScope) - sttModel.ensure() - - // Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике - // (нативка лениво) и 30s-лимит на тишину. Куда уходит готовый текст (onResult) регистрирует - // владелец — слой записи (ListeningLayout.onEnter); приложение результат к mainActivity не шьёт. - asr = GlassesAsr(File(filesDir, "models")) - sttSession = GlassesSttSession( - filesDir = filesDir, - scope = bgScope, - recognizer = LocalGlassesRecognizer(asr, sttModel), - ) + // Весь стек распознавания очков (скачивание + статус + сессия микрофона) — в одном объекте. + // Модель если отсутствует докачивается фоновой корутиной стта (idempotent, по Range). Куда + // уходит готовый текст (onResult) регистрирует владелец — слой записи (ListeningLayout.onEnter); + // приложение результат к mainActivity не шьёт. + stt = GlassesStt(filesDir, bgScope) + stt.ensure() // Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager, // navStack — лишь его зеркало. Начальная проекция (корень = Film). 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 728bca3..1fb9eb3 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 @@ -65,7 +65,7 @@ fun AssistantOverlay(engine: AssistantEngine) { // «Модель не скачалась» выводится из состояния догрузчика (отдельного логического флага в // движке нет): распознавание локально, поэтому модель не READY = распознавание не готово — // объясняем что происходит (качается / не скачалась). - val modelStatus by app.sttModel.status.collectAsState() + val modelStatus by app.stt.status.collectAsState() val state = AssistantState( stack = navStack, thinking = thinking, 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 f328ee4..8344923 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,15 +1,13 @@ 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.stt.GlassesStt import pw.binom.viewmate.glasses.ui.AssistantOverlay import kotlin.coroutines.resume @@ -26,8 +24,7 @@ import kotlin.coroutines.resume class AssistantLayout( private val ctx: LayoutContext, private val engine: AssistantEngine, - private val sttSession: GlassesSttSession, - private val modelStatus: StateFlow, + private val stt: GlassesStt, ) : Layout { @Composable @@ -70,7 +67,7 @@ class AssistantLayout( * Возвращает распознанный текст ([onSuccess]) или null ([onCancel], любая отмена) — * слой записи завершает ожидание этими кэлбэками. */ - private suspend fun listen() = ListeningLayout.listen(ctx, sttSession, modelStatus) + private suspend fun listen() = ListeningLayout.listen(ctx, stt) /** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */ private fun openListening() { 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 bd0709a..3612df0 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 @@ -2,33 +2,30 @@ package pw.binom.viewmate.glasses.ui.layers import androidx.compose.runtime.Composable 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 pw.binom.viewmate.glasses.stt.GlassesStt import kotlin.coroutines.resume /** * Слой записи — ЕДИНСТВЕННЫЙ, кто знает про микрофон и распознавание. Он сам открывает - * микрофон ([GlassesSttSession.start] в [onEnter]), закрывает ([onExit]) и сам принимает - * стт-решение: клик распознаёт накопленное ([GlassesSttSession.send]), результат/события + * микрофон ([stt.session.start] в [onEnter]), закрывает ([onExit]) и сам принимает + * стт-решение: клик распознаёт накопленное ([stt.session.send]), результат/события * (локальный [onSttResult], хостовые [onSttPhrase]/[onSttCancelUser]/[onSttTimeout]) слой * переваривает сам и сообщает вызывающему факт исхода кэлбэками [onSuccess]/[onCancel]. * * Для всего остального (кино, чат, ассистент) «текст откуда-то берётся» — никто кроме этого - * слоя не в курсе микрофона и распознавания. Зависимости — конкретные стт-сущности - * ([sttSession] и состояние готовности модели [modelStatus]), а не весь [GlassesApp]; + * слоя не в курсе микрофона и распознавания. Зависимость — единый объект [GlassesStt] (весь + * стек распознавания: скачивание, статус, сессия микрофона), а не весь [GlassesApp]; * исхода двух видов: успех — [onSuccess] с текстом, ЛЮБАЯ отмена (двойной тап, тишина, * модель не готова) — [onCancel]; выхода из режима слой не делает — это уже эффект вызывающего. */ class ListeningLayout( private val ctx: LayoutContext, - private val sttSession: GlassesSttSession, - private val modelStatus: StateFlow, + private val stt: GlassesStt, private val onSuccess: (String) -> Unit, private val onCancel: () -> Unit, ) : Layout { @@ -36,14 +33,13 @@ class ListeningLayout( companion object { suspend fun listen( ctx: LayoutContext, - sttSession: GlassesSttSession, - modelStatus: StateFlow, + stt: GlassesStt, ) = suspendCancellableCoroutine { context -> ctx.open { c -> context.invokeOnCancellation { c.close() } - ListeningLayout(c, sttSession, modelStatus, onSuccess = { text -> + ListeningLayout(c, stt, onSuccess = { text -> context.resume(text) }, onCancel = { context.resume(null) @@ -55,7 +51,7 @@ class ListeningLayout( /** Накопленный распознанный текст — живой стейт слоя (оверлей читает через вершину стека). */ val sttText = MutableStateFlow("") - private val modelReady: Boolean get() = modelStatus.value.ready + private val modelReady: Boolean get() = stt.status.value.ready /** Прозрачный слой: сам ничего не рисует — оверлей ассистента (поверх) показывает индикатор и фразу. */ @Composable @@ -69,15 +65,15 @@ class ListeningLayout( sttText.value = "" // Регистрирую себя обработчиком готового текста: слой сам заводит свой результат, // сессия лишь порождает текст — куда он идёт, знает только владелец (этот слой). - sttSession.onResult = { text, auto -> onSttResult(text, auto) } - sttSession.start() + stt.session.onResult = { text, auto -> onSttResult(text, auto) } + stt.session.start() log("stt", "слои: вхожу в режим записи, стартую сессию + микрофон") } /** Слой закрыт: микрофон закрыть + безопасная отмена ожидания (resume однократен, повтор — no-op). */ override fun onExit() { - sttSession.onResult = null - sttSession.stop() + stt.session.onResult = null + stt.session.stop() onCancel() } @@ -90,10 +86,10 @@ class ListeningLayout( Gesture.Click -> if (modelReady) { log("stt", "клик → распознаю накопленное") - sttSession.send() + stt.session.send() } else { log("stt", "клик: модель не готова — повторю старт записи") - sttSession.start() + stt.session.start() } Gesture.DoubleClick -> @@ -115,7 +111,7 @@ class ListeningLayout( /** * Обработчик результата ЛОКАЛЬНОГО распознавания. Слой сам заводит свой результат: - * регистрируется на [GlassesSttSession.onResult] в [onEnter], сбрасывает в [onExit] — + * регистрируется на [stt.session.onResult] в [onEnter], сбрасывает в [onExit] — * извне (телефон/LLM-путь) [onSttResult] не вызывают. Слой сам решает исход: * - текст не пуст → успех ([onSuccess]) + POP слоя; * - пусто + авто (тишина 30с) → отмена ([onCancel]) + POP слоя; 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 6bb01b4..aab9634 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 @@ -84,7 +84,7 @@ class MoveLayout( } app.hostConnection.sendPlaybackPositionNow() ctx.open { c -> - AssistantLayout(c, app.assistantEngine, app.sttSession, app.sttModel.status) + AssistantLayout(c, app.assistantEngine, app.stt) } } }