Слои записи и оверлей берут стт-статус и сессию из единого 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.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).
@@ -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,
@@ -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<SttModelStatus>,
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() {
@@ -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<SttModelStatus>,
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<SttModelStatus>,
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 слоя;
@@ -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)
}
}
}