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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user