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

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.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,
@@ -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()
}
}
}
@@ -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:
@@ -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) }
}
}
@@ -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 и коллекторы должны существовать
@@ -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<List<ChatEntry>>(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<ChatEntry>) {
chatHistory.value = messages
}
/** SttPhrase: накопленный текст распознавания (для оверлея). */
fun onSttPhrase(full: String) {
sttText.value = full
}
/** SttDone: распознавание завершено, индикатор записи гаснет. */
fun onStopListening() {
listening.value = false
}
}
/** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */
@@ -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<SttModelStatus>,
) : 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(
"отмена ввода → остаюсь в ассистенте",
@@ -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<SttModelStatus>,
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<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
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()
}
}
@@ -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)
}
}
}