Слой записи владеет микрофоном и распознаванием, движок — только диалогом
This commit is contained in:
@@ -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 и коллекторы должны существовать
|
||||
|
||||
+30
-159
@@ -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]-терминах). */
|
||||
|
||||
+14
-15
@@ -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(
|
||||
"отмена ввода → остаюсь в ассистенте",
|
||||
|
||||
+127
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user