Слой записи владеет микрофоном и распознаванием, движок — только диалогом

This commit is contained in:
2026-09-07 03:27:31 +03:00
parent 1b817aa9a6
commit 8083978849
9 changed files with 257 additions and 260 deletions
@@ -17,6 +17,7 @@ import pw.binom.viewmate.glasses.stt.GlassesSttSession
import pw.binom.viewmate.glasses.stt.LocalGlassesRecognizer import pw.binom.viewmate.glasses.stt.LocalGlassesRecognizer
import pw.binom.viewmate.glasses.stt.SttModelDownloader 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.MoveLayout import pw.binom.viewmate.glasses.ui.layers.MoveLayout
import pw.binom.viewmate.glasses.ui.layers.toFrame import pw.binom.viewmate.glasses.ui.layers.toFrame
@@ -106,8 +107,10 @@ class GlassesApp : Application() {
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */ /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f) val vertical = MutableStateFlow(1f)
// Стейт ассистента (listening/sttText/assistantText/chatHistory/thinking/sttStartBlocked // Стейт диалога ассистента (thinking/assistantText/chatHistory/answerScrollDelta + idle-hint
// + sttNoSpeech/answerScrollDelta ниже) вынесен в [assistantEngine] — владелец стейта. // sttNoSpeech) — в [assistantEngine], владелец стейта ассистента. Микрофон и распознавание
// (sttText, старт/стоп стт-сессии) владеет слой [ListeningLayout]; «модель не скачалась» —
// из sttModel.status. Движок микрофоном не владеет.
/** /**
* Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не * Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не
@@ -143,6 +146,38 @@ class GlassesApp : Application() {
prefs.edit().putFloat("vertical", v).apply() 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() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
@@ -180,13 +215,13 @@ class GlassesApp : Application() {
sttModel.ensure() sttModel.ensure()
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике // Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity. // (нативка лениво) и 30s-лимит на тишину. Куда уходит готовый текст (onResult) регистрирует
// владелец — слой записи (ListeningLayout.onEnter); приложение результат к mainActivity не шьёт.
asr = GlassesAsr(File(filesDir, "models")) asr = GlassesAsr(File(filesDir, "models"))
sttSession = GlassesSttSession( sttSession = GlassesSttSession(
filesDir = filesDir, filesDir = filesDir,
scope = bgScope, scope = bgScope,
recognizer = LocalGlassesRecognizer(asr, sttModel), recognizer = LocalGlassesRecognizer(asr, sttModel),
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
) )
// Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager, // Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager,
@@ -369,13 +369,15 @@ class HostConnection(
} }
is SttPhrase -> { is SttPhrase -> {
GlassesApp.instance.assistantEngine.onSttPhrase(msg.full) // Хостовую накопленную фразу отдаю *верхнему слою* (как и SttCancel):
// микрофон/распознавание владеет слой записи, а транспорт о слоях не знает
// (роутинг — в [GlassesApp]).
GlassesApp.instance.onSttPhrase(msg.full)
log("stt", "фраза: ${msg.phrase}") log("stt", "фраза: ${msg.phrase}")
} }
is SttDone -> { is SttDone -> {
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}") log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
GlassesApp.instance.assistantEngine.onStopListening()
} }
is SttCancel -> { is SttCancel -> {
@@ -391,15 +393,13 @@ class HostConnection(
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode. // режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
log("stt", "тишина 30с — выход из режима общения") log("stt", "тишина 30с — выход из режима общения")
GlassesApp.instance.mainActivity?.onSttTimeout() GlassesApp.instance.onSttTimeout()
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
} }
else -> { else -> {
// reason="user" — пустой клик без голоса: не выходим из режима, // reason="user" — пустой клик без голоса: не выходим из режима,
// стоп-записи с остаованием в ассистенте (idle), «не расслышал». // стоп-записи с остаованием в ассистенте (idle), «не расслышал».
log("stt", "отменено — стоп, остаюсь в ассистенте (не расслышал)") log("stt", "отменено — стоп, остаюсь в ассистенте (не расслышал)")
GlassesApp.instance.mainActivity?.stopSttListening() GlassesApp.instance.stopSttListening()
?: log("stt", "mainActivity ещё нет — не могу сделать стоп")
} }
} }
} }
@@ -48,7 +48,6 @@ 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.actionName import pw.binom.viewmate.glasses.gestures.actionName
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
@@ -210,33 +209,8 @@ class MainActivity : BaseEventActivity() {
// Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]: // Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]:
// слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout. // слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout.
// Хостовые STT-события (SttPhrase/SttCancel) маршрутизирует [GlassesApp] в верхний
/** // слой — Activity о распознавании не знает.
* 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()
/** /**
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
@@ -31,17 +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: GlassesRecognizer, 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) }, 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
@@ -139,7 +143,7 @@ class GlassesSttSession(
log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено") log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено")
"" ""
} }
withContext(Dispatchers.Main) { onResult(text, auto) } withContext(Dispatchers.Main) { onResult?.invoke(text, auto) }
} }
} }
@@ -36,6 +36,7 @@ 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.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.ListeningLayout
/** /**
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт * Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
@@ -53,24 +54,28 @@ fun AssistantOverlay(engine: AssistantEngine) {
// Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine]. // Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine].
val navStack by app.navStack.collectAsState() val navStack by app.navStack.collectAsState()
val thinking by engine.thinking.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 assistantText by engine.assistantText.collectAsState()
val sttStartBlocked by engine.sttStartBlocked.collectAsState()
val sttNoSpeech by engine.sttNoSpeech.collectAsState() val sttNoSpeech by engine.sttNoSpeech.collectAsState()
val chatHistory by engine.chatHistory.collectAsState() val chatHistory by engine.chatHistory.collectAsState()
// «Модель не скачалась» выводится из состояния догрузчика (отдельного логического флага в
// движке нет): распознавание локально, поэтому модель не READY = распознавание не готово —
// объясняем что происходит (качается / не скачалась).
val modelStatus by app.sttModel.status.collectAsState()
val state = AssistantState( val state = AssistantState(
stack = navStack, stack = navStack,
thinking = thinking, thinking = thinking,
sttText = sttText, sttText = sttText,
assistantText = assistantText, assistantText = assistantText,
sttStartBlocked = sttStartBlocked, sttStartBlocked = !modelStatus.ready,
sttNoSpeech = sttNoSpeech, sttNoSpeech = sttNoSpeech,
) )
if (!overlayVisible(state)) return if (!overlayVisible(state)) return
val modelStatus by app.sttModel.status.collectAsState() // Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ модель не READY («не скачалась»).
// Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ старт отклонён
// (модель не скачалась, стtStartBlocked): распознавание локально, поэтому модель не
// READY = распознавание не готово — объясняем что происходит (качается / не скачалась).
val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды // Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать // (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
@@ -4,60 +4,49 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import pw.binom.viewmate.core.protocol.SttText
import pw.binom.viewmate.glasses.ChatEntry import pw.binom.viewmate.glasses.ChatEntry
import pw.binom.viewmate.glasses.GlassesApp import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.log import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.gestures.AssistantMode import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.gestures.NavFrame import pw.binom.viewmate.glasses.gestures.NavFrame
/** /**
* Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные * Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные
* flows в [GlassesApp]. Слои [AssistantLayout]/[ListeningLayout] получают движок и *сами* * flows в [GlassesApp]. Держит ТОЛЬКО диалог ассистента с телефоном ([thinking]/[assistantText]/
* владеют таблицами жестов, вызывая эффекты здесь; чат-оверлей читает стейт тоже отсюда. * [chatHistory]/[answerScrollDelta]) и его реакции на ввод ([continueTurn]/[stopToIdle]).
* *
* Зависимости (STT-сессия, модель, протокол, медиа-контроллер, менеджер стека) берёт лениво из * Микрофон и распознавание движку НЕ ЗНАЕТ: всё стт живёт в [ListeningLayout] (единственный, кто
* [GlassesApp] в методах — сам движок строится без побочных эффектов (его стейт — лишь flows), * в курсе микрофона и распознавания); для движка ввод — просто «текст пришёл» ([continueTurn]) или
* поэтому порядок инициализации полей приложения не критичен. * «ввод отменён» ([stopToIdle]). Чат-оверлей читает стейт тоже отсюда.
*
* Зависимости (протокол, менеджер стека) берёт лениво из [GlassesApp] в методах — сам движок
* строится без побочных эффектов (его стейт — лишь flows), порядок инициализации полей не критичен.
*/ */
class AssistantEngine(private val app: GlassesApp) { class AssistantEngine(private val app: GlassesApp) {
// ── Стейт ассистента (были размазанными полями в GlassesApp) ────────────────────── // ── Стейт диалога ассистента (был размазанными полями в GlassesApp) ───────────────
/** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */ /** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */
val thinking = MutableStateFlow(false) val thinking = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
val sttText = MutableStateFlow("")
/** Текст ответа ассистента (ShowText); остаётся до новой сессии. */ /** Текст ответа ассистента (ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("") val assistantText = MutableStateFlow("")
/** Попытка начать диктовку отклонена: модель Qwen3 не скачалась. */ /**
val sttStartBlocked = MutableStateFlow(false) * У ассистента «не расслышал»: последний ввод отменили без голоса — оверлей показывает
* «не расслышал, тапни ещё раз» (idle-hint). Это стейт *отображения* ассистента (факт
/** Последний стоп/отправка не поймал голоса — оверлей показывает «не расслышал». */ * отмены несёт [stopToIdle]), не стт-факт — распознанным текстом движок не владеет.
*/
val sttNoSpeech = MutableStateFlow(false) val sttNoSpeech = MutableStateFlow(false)
/** Идёт распознавание (TripleClick открыл, завершает Click / 30s-лимит). */
val listening = MutableStateFlow(false)
/** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */ /** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList()) val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */ /** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */
val answerScrollDelta = MutableStateFlow(0f) 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) val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@@ -73,54 +62,14 @@ class AssistantEngine(private val app: GlassesApp) {
// ── Эффекты (перенесены из GlassesGestureEffects) ──────────────────────────────── // ── Эффекты (перенесены из GlassesGestureEffects) ────────────────────────────────
/** /**
* TripleClick в кино: пауза фильма + открыть кадр «ассистент» ([AssistantLayout]) и * Кадр ассистента открыт ([AssistantLayout.onEnter]) — чистый старт сессии: стираем прошлый
* сразу «слушание» ([ListeningLayout]). Фильм остаётся на паузе, аудиосинхр улетает на * ответ / «не расслышал» / дельту скролла. Паузу фильма и синх позиций делает ВЛАДЕЛЕЦ (слой
* телефон (продолжит догнанку, не перемотает). * кино), а не движок: здесь чистится только *свой* стейт.
*/ */
fun enterAssistant() { fun beginSession() {
if (app.layoutManager.depth > 1) {
log("gesture", "TripleClick проигнорирован: уже в ассистенте")
return
}
log("stt", "ассистент: слушаю (стек: фильм → ассистент → слушаю)")
if (app.rootLayer.controller.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
app.hostConnection.sendPlaybackPositionNow()
sttNoSpeech.value = false sttNoSpeech.value = false
assistantText.value = "" assistantText.value = ""
answerScrollDelta.value = 0f 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) { suspend fun continueTurn(text: String) {
listeningSuccess = onSuccess log("stt", "ассистент: фраза «${text.take(40)}» → на телефон (LLM), жду ответ")
listeningCancel = onCancel app.hostConnection.sendToHost(SttText(text))
}
/** Слой записи закрыт ([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
thinking.value = true thinking.value = true
} }
/** Клик в «слушаю»: распознать скопленный микрофон локально Qwen3-ом и отправить на телефон. */
fun sendPhrase() {
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
thinking.value = true
app.sttSession.send()
}
/** /**
* Стоп-записи с ОСТАВАНИЕМ в кадре ассистента: POP [ListeningLayout] (idle) — его onExit * Ввод отменён ([ListeningLayout.onCancel] → вызывающий [AssistantLayout]): стоп, остаюсь в
* закрывает микрофон ([endMic]). Микрофон закрывается ПОД-слоем, а не здесь. * ассистенте (idle). [sttNoSpeech] — idle-hint «не расслышал» (факт отмены, не стт-текст).
*/ */
fun stopToIdle(reason: String, noSpeech: Boolean) { fun stopToIdle(reason: String, noSpeech: Boolean) {
log("stt", reason) log("stt", reason)
if (app.layoutManager.current is ListeningLayout) app.layoutManager.close() // onExit → endMic
thinking.value = false thinking.value = false
sttNoSpeech.value = noSpeech sttNoSpeech.value = noSpeech
} }
/** Выход из режима в корень: closeToRoot (POP [ListeningLayout] → onExit → [endMic]), чистим стейт. */ /** Выход из режима в корень: closeToRoot (слои POP-нуты, их onExit сам закроет своё), чистим стейт. */
fun exitMode(reason: String) { fun exitMode(reason: String) {
log("stt", reason) log("stt", reason)
app.layoutManager.closeToRoot() // POP Listening → onExit → endMic (стоп микрофона) app.layoutManager.closeToRoot()
thinking.value = false thinking.value = false
sttStartBlocked.value = false
sttNoSpeech.value = false sttNoSpeech.value = false
sttText.value = ""
assistantText.value = "" assistantText.value = ""
answerScrollDelta.value = 0f 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) ────────── // ── Host-driven записи стейта (вместо прямого писания flows в GlassesApp) ──────────
/** ShowText: ответ ассистента виден, «думаю» сброшено. */ /** ShowText: ответ ассистента виден, «думаю» сброшено. */
@@ -242,16 +123,6 @@ class AssistantEngine(private val app: GlassesApp) {
fun onChatHistory(messages: List<ChatEntry>) { fun onChatHistory(messages: List<ChatEntry>) {
chatHistory.value = messages chatHistory.value = messages
} }
/** SttPhrase: накопленный текст распознавания (для оверлея). */
fun onSttPhrase(full: String) {
sttText.value = full
}
/** SttDone: распознавание завершено, индикатор записи гаснет. */
fun onStopListening() {
listening.value = false
}
} }
/** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */ /** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */
@@ -1,12 +1,15 @@
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.SttModelStatus
import pw.binom.viewmate.glasses.ui.AssistantOverlay import pw.binom.viewmate.glasses.ui.AssistantOverlay
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -23,6 +26,8 @@ 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 modelStatus: StateFlow<SttModelStatus>,
) : Layout { ) : Layout {
@Composable @Composable
@@ -33,6 +38,11 @@ class AssistantLayout(
@Composable @Composable
override fun drawStatusPanel() = Unit override fun drawStatusPanel() = Unit
/** Кадр ассистента открыт: чистый старт сессии — стираем прошлый ответ / «не расслышал» / дельту скролла. */
override fun onEnter() {
engine.beginSession()
}
/** /**
* Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк); * Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк);
* логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ). * логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ).
@@ -57,28 +67,17 @@ class AssistantLayout(
/** /**
* Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания. * Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания.
* Возвращает распознанный текст ([onSuccess]) или null ([onCancel], отмена) — слой записи * Возвращает распознанный текст ([onSuccess]) или null ([onCancel], любая отмена) —
* завершает ожидание этими кэлбэками (текст доставляет движок, отмену слой вызовал сам). * слой записи завершает ожидание этими кэлбэками.
*/ */
private suspend fun listen() = suspendCancellableCoroutine { context -> private suspend fun listen() = ListeningLayout.listen(ctx, sttSession, modelStatus)
ctx.open { c ->
context.invokeOnCancellation {
c.close()
}
ListeningLayout(c, engine, onSuccess = { text ->
context.resume(text)
}, onCancel = {
context.resume(null)
})
}
}
/** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */ /** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */
private fun openListening() { private fun openListening() {
engine.scope.launch { engine.scope.launch {
val text = listen() val text = listen()
if (text != null) { if (text != null) {
engine.continueTurn(text) // распознали фразу → жду ответ (thinking, POP записи) engine.continueTurn(text) // распознали фразу → жду ответ (thinking); слой записи сам себя POP-нул
} else { } else {
engine.stopToIdle( engine.stopToIdle(
"отмена ввода → остаюсь в ассистенте", "отмена ввода → остаюсь в ассистенте",
@@ -1,71 +1,163 @@
package pw.binom.viewmate.glasses.ui.layers 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.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.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.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] (закрыть). * слоя не в курсе микрофона и распознавания. Зависимости — конкретные стт-сущности
* * ([sttSession] и состояние готовности модели [modelStatus]), а не весь [GlassesApp];
* Слой не «знает», что делать с результатом — он возвращает его ВЫЗЫВШИМУ (слою ассистента) двумя * исхода двух видов: успех — [onSuccess] с текстом, ЛЮБАЯ отмена (двойной тап, тишина,
* кэлбэками, которые тот передал при вызове: [onSuccess] (сразу текст распознанной фразы) и * модель не готова) — [onCancel]; выхода из режима слой не делает — это уже эффект вызывающего.
* [onCancel] (просто отмена ввода). Распознанный текст движок доставляет сюда через
* [AssistantEngine.bindListening] (привязан в [onEnter], отцепляется в [onExit]); кэки-жесты слой
* вызывает [onCancel] сам. Если модель не скачалась (sttStartBlocked) — клик пере-начинает попытку,
* двойной клик выходит из режима.
*/ */
class ListeningLayout( class ListeningLayout(
private val ctx: LayoutContext, private val ctx: LayoutContext,
private val engine: AssistantEngine, private val sttSession: GlassesSttSession,
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 {
@Composable companion object {
override fun drawStage() { suspend fun listen(
Box(modifier = Modifier.fillMaxSize()) ctx: LayoutContext,
sttSession: GlassesSttSession,
modelStatus: StateFlow<SttModelStatus>,
) = 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 @Composable
override fun drawStatusPanel() = Unit override fun drawStatusPanel() = Unit
/** Слой записи открыт: привязать кэлбэки вызвавшего к движку + открыть микрофон (если модель готова). */ /** Слой открыт: открыть микрофон (само гейтится готовностью модели: не ready → старт молчит). */
override fun onEnter() { override fun onEnter() {
engine.bindListening(onSuccess, onCancel) sttText.value = ""
engine.beginMic() // Регистрирую себя обработчиком готового текста: слой сам заводит свой результат,
// сессия лишь порождает текст — куда он идёт, знает только владелец (этот слой).
sttSession.onResult = { text, auto -> onSttResult(text, auto) }
sttSession.start()
log("stt", "слои: вхожу в режим записи, стартую сессию + микрофон")
} }
/** /** Слой закрыт: микрофон закрыть + безопасная отмена ожидания (resume однократен, повтор — no-op). */
* Слой записи закрыт: отцепить кэлбэки + закрыть микрофон. [onCancel] — страховочный вызов:
* закрытие без успешного текста (выход из режима, стоп) сообщает вызывающему об отмене. Для
* вызывающего [onCancel] идемпотентен (deferred уже завершён — повтор игнорируется).
*/
override fun onExit() { override fun onExit() {
engine.endMic() sttSession.onResult = null
engine.unbindListening() sttSession.stop()
onCancel() onCancel()
} }
/** Таблица жестов под-состояния (top == Listening): этот слой *занимается* микрофоном. */ /**
* Жесты слоя: клик — распознать накопленное (модель не ready — повторить попытку старта);
* двойной — отмена (стоп, остаюсь в ассистенте) или выход (модель не скачалась). Остальное — игнор.
*/
override fun gesture(g: Gesture) { override fun gesture(g: Gesture) {
when (g) { when (g) {
Gesture.Click -> Gesture.Click ->
if (engine.sttStartBlocked.value) engine.beginMic() else engine.sendPhrase() if (modelReady) {
Gesture.DoubleClick -> log("stt", "клик → распознаю накопленное")
if (engine.sttStartBlocked.value) { sttSession.send()
engine.exitMode("DoubleClick → выход (модель не скачалась)") // onExit → onCancel
} else { } 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()
}
} }
@@ -49,7 +49,7 @@ class MoveLayout(
when (g) { when (g) {
Gesture.Click -> togglePlayback() Gesture.Click -> togglePlayback()
Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore) Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore)
Gesture.TripleClick -> GlassesApp.instance.assistantEngine.enterAssistant() Gesture.TripleClick -> callAssistant()
Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000) Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000)
Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000) Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000)
} }
@@ -70,4 +70,21 @@ class MoveLayout(
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() 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)
}
}
} }