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
@@ -80,3 +80,18 @@ data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlas
@Serializable
@SerialName("set_scale")
data class SetScale(val scale: Float) : HostToGlasses
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
@Serializable
@SerialName("stt_phrase")
data class SttPhrase(val phrase: String, val full: String) : HostToGlasses
/** Распознавание завершено (клик): полный текст сессии. */
@Serializable
@SerialName("stt_done")
data class SttDone(val full: String) : HostToGlasses
/** Распознавание отменено (двойной клик или 30 с тишины) — текст не выводится. */
@Serializable
@SerialName("stt_cancel")
class SttCancel : HostToGlasses
@@ -81,3 +81,24 @@ data class GlassesOff(val reason: String = "display_off") : GlassesToHost
@Serializable
@SerialName("hello")
data class Hello(val appVersion: String = "0.1.0") : GlassesToHost
/**
* Кусок аудио для распознавания (очки → телефон): s16le, 16 кГц, mono,
* ~100-200 мс. Текстовый JSON-фрейм: ByteArray кодируется base64.
*/
@Serializable
@SerialName("stt_audio")
data class SttAudio(val data: ByteArray) : GlassesToHost {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SttAudio) return false
return data.contentEquals(other.data)
}
override fun hashCode(): Int = data.contentHashCode()
}
/** Запрос завершения распознавания: cancel=true — отмена (текст не выводить). */
@Serializable
@SerialName("stop_stt")
data class StopStt(val cancel: Boolean) : GlassesToHost
@@ -181,4 +181,33 @@ class GlassesToHostTest {
val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("gesture", obj["type"]!!.jsonPrimitive.content)
}
@Test
fun sttAudioRoundTrip() {
val pcm = byteArrayOf(0x01, 0x00, -1, 0x7f, 0x34, 0x12)
val msg: GlassesToHost = SttAudio(pcm)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
assertTrue(text.contains("\"type\":\"stt_audio\""))
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<SttAudio>(decoded)
assertTrue(decoded.data.contentEquals(pcm))
}
@Test
fun stopSttRoundTrip() {
val msg: GlassesToHost = StopStt(cancel = false)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
assertEquals("""{"type":"stop_stt","cancel":false}""", text)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<StopStt>(decoded)
assertEquals(false, decoded.cancel)
}
@Test
fun stopSttCancelRoundTrip() {
val msg: GlassesToHost = StopStt(cancel = true)
val decoded = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), msg))
assertIs<StopStt>(decoded)
assertTrue(decoded.cancel)
}
}
@@ -192,4 +192,34 @@ class HostToGlassesTest {
val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("set_mode", obj["type"]!!.jsonPrimitive.content)
}
@Test
fun sttPhraseRoundTrip() {
val msg: HostToGlasses = SttPhrase(phrase = "раз два", full = "раз два три")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"stt_phrase","phrase":"раз два","full":"раз два три"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SttPhrase>(decoded)
assertEquals("раз два", decoded.phrase)
assertEquals("раз два три", decoded.full)
}
@Test
fun sttDoneRoundTrip() {
val msg: HostToGlasses = SttDone(full = "весь текст")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"stt_done","full":"весь текст"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SttDone>(decoded)
assertEquals("весь текст", decoded.full)
}
@Test
fun sttCancelRoundTrip() {
val msg: HostToGlasses = SttCancel()
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"stt_cancel"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SttCancel>(decoded)
}
}