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
@@ -28,8 +28,13 @@ import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
import pw.binom.viewmate.phone.stt.SttStreamer
import java.util.concurrent.ConcurrentHashMap
const val GLASSES_WS_PATH = "/ws/glasses"
@@ -93,6 +98,18 @@ class GlassesHub(
*/
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null
/**
* Стриминговое распознавание речи с очков. Лениво создаётся через
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s
* рассылаются через [broadcast].
*/
@Volatile
var stt: SttStreamer? = null
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
@Volatile
var sttFactory: (() -> SttStreamer?)? = null
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
_connected.value = sessions.size
@@ -119,7 +136,12 @@ class GlassesHub(
/** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), text)
val msg = try {
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
} catch (e: Exception) {
log("hub", "ОШИБКА декодирования входящего: ${e::class.simpleName}: ${e.message} (текст: ${text.take(120)})")
return
}
when (msg) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
@@ -168,6 +190,24 @@ class GlassesHub(
this.state.playing = false
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
}
is SttAudio -> {
val s = stt ?: sttFactory?.invoke()?.also { stt = it }
s?.accept(msg.data)
}
is StopStt -> {
if (msg.cancel) {
log("stt", "распознавание отменено (очки)")
send(session, SttCancel())
stt?.reset()
} else {
val full = stt?.finish() ?: ""
log("stt", "ВЕСЬ ТЕКСТ: $full")
send(session, SttDone(full))
stt?.reset()
}
}
}
}
@@ -197,6 +237,7 @@ fun Application.glassesServerModule(hub: GlassesHub) {
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
hub.remove(this)
}