Слои записи и оверлей берут стт-статус и сессию из единого GlassesStt

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