feat: голосовой ввод на очках — TripleClick + PCM → телефон → текст на очки

Протокол: SttAudio (PCM s16 16k mono кусками), SttPhrase/SttDone/SttCancel
(host→очки), StopStt(cancel) (очки→host).

Очки: TripleClick — безусловный вызов (кино: пауза + затемнение + слушаем);
во время распознавания Click — завершить (весь текст в консоль, затемнение
уходит, видео на паузе), DoubleClick — отмена; VAD-тишина 30с — автоотмена.
Микрофон (AudioRecord 16k) → SttAudio; на эмуляторе — PCM из filesDir/samples
(куски 3200 + пауза 200мс). Оверлей текста в Binocular (полупрозрачный фон,
текст по центру, в оба глаза). RECORD_AUDIO в манифесте. DEBUG-ресивер
(am broadcast DEBUG_GESTURE type=triple_click|click|double_click).

Телефон: SttStreamer — стриминговый VAD (Silero fp32, окна 512) → фразы →
Whisper-small int8 (executor, не блокирует приём) → SttPhrase на очки;
StopStt → SttDone (полный текст) / SttCancel; watchdog 30с тишины.

Фиксы (найдены на эмуляторе): IllegalThreadStateException — двойной старт
watchdog-потока (thread{}.apply{start()}); decode входящих — try/catch с
логом (не роняет сессию).

Проверено на эмуляторах (5554 очки + 5556 телефон, forward 8080):
triple_click → фразы «Раз, два» и «3 4 5» на очках (оверлей) → click →
«ВЕСЬ ТЕКСТ: Раз, два 3 4 5»; 30с-тишина → «отменено». Тесты: lib-core,
phone, glasses — зелёные.
This commit is contained in:
Hermes Agent
2026-08-20 04:12:28 +03:00
parent 324c427c74
commit 1304c6d921
16 changed files with 818 additions and 9 deletions
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -33,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -40,11 +42,17 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.rayneo.arsdk.android.touch.TempleAction
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.SttAudio
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
@@ -53,6 +61,27 @@ import pw.binom.viewmate.glasses.ui.px
class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
}
/**
* DEBUG-имитация жестов (эмулятор без трекпада):
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
* Типы: triple_click / click / double_click. Оставлен для отладки.
*/
private val debugReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.getStringExtra("type")) {
"triple_click" -> handleTripleClick()
"click" -> handleClick()
"double_click" -> handleDoubleClick()
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
}
}
}
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
@@ -111,6 +140,22 @@ class MainActivity : BaseEventActivity() {
}
registerReceiver(screenReceiver, filter)
// DEBUG-имитация жестов на эмуляторе (см. debugReceiver). RECEIVER_EXPORTED
// на 33+ — shell `am broadcast` ходит под uid shell и не достучится до
// NOT_EXPORTED-ресивера. Ресивер ДЕБАГОВЫЙ (симулирует жесты) — см. TASK.md.
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(
debugReceiver,
IntentFilter("pw.binom.viewmate.glasses.DEBUG_GESTURE"),
Context.RECEIVER_EXPORTED,
)
} else {
registerReceiver(
debugReceiver,
IntentFilter("pw.binom.viewmate.glasses.DEBUG_GESTURE"),
)
}
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
dm.registerDisplayListener(object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
@@ -137,25 +182,79 @@ class MainActivity : BaseEventActivity() {
}, null)
}
/** Жесты трекпада → команды просмотра (Click=пауза/плей, свайпы=±30с). */
/**
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
* голосовой ввод (безусловный вызов), Click во время прослушивания —
* завершить распознавание, DoubleClick — отмена.
*/
private fun collectTempleActions() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action ->
val cmd = templeActionToCommand(action)
if (cmd != null) {
log("gesture", "жест: ${actionName(action)} → ${cmd.command}")
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
when (action) {
is TempleAction.TripleClick -> handleTripleClick()
is TempleAction.Click -> handleClick()
is TempleAction.DoubleClick -> handleDoubleClick()
is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
val cmd = templeActionToCommand(action)
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
if (cmd != null) {
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
}
} else {
log("gesture", "жест (резерв): ${actionName(action)}")
else -> log("gesture", "жест (резерв): ${actionName(action)}")
}
}
}
}
}
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
private fun handleTripleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
return
}
log("stt", "ассистент: слушаю")
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick)")
}
GlassesApp.instance.listening.value = true
GlassesApp.instance.sttText.value = ""
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
}
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
private fun handleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "Click → завершить распознавание")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
return
}
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
private fun handleDoubleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
}
return
}
log("gesture", "жест (резерв): DoubleClick")
}
/**
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
* mercury_system_single_tap/fling/double_click/long_press) — это и есть
@@ -211,6 +310,7 @@ class MainActivity : BaseEventActivity() {
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(screenReceiver)
unregisterReceiver(debugReceiver)
}
/**
@@ -275,6 +375,29 @@ fun ConnectionScreen() {
StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
)
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
val listening by GlassesApp.instance.listening.collectAsState()
val sttText by GlassesApp.instance.sttText.collectAsState()
if (listening) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f)),
) {
Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (sttText.isBlank()) "слушаю..." else sttText,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
}
}