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
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
@@ -36,6 +36,12 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */ /** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
val scale = MutableStateFlow(1f) val scale = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
val listening = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
val sttText = MutableStateFlow("")
/** Применить и сохранить масштаб. */ /** Применить и сохранить масштаб. */
fun applyScale(value: Float) { fun applyScale(value: Float) {
val v = value.coerceIn(0.5f, 1f) val v = value.coerceIn(0.5f, 1f)
@@ -32,6 +32,9 @@ import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.Welcome
/** /**
@@ -267,6 +270,21 @@ class HostConnection(
is ShowText -> addMessage("ShowText: ${msg.text}") is ShowText -> addMessage("ShowText: ${msg.text}")
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full
log("stt", "фраза: ${msg.phrase}")
}
is SttDone -> {
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
GlassesApp.instance.listening.value = false
}
is SttCancel -> {
log("stt", "отменено")
GlassesApp.instance.listening.value = false
}
} }
} }
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -33,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -40,11 +42,17 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.rayneo.arsdk.android.touch.TempleAction
import com.rayneo.arsdk.android.ui.activity.BaseEventActivity import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
import kotlinx.coroutines.launch 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.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.status.StatusPanelCompose 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.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
@@ -53,6 +61,27 @@ import pw.binom.viewmate.glasses.ui.px
class MainActivity : BaseEventActivity() { class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false 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() { private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) { when (intent?.action) {
@@ -111,6 +140,22 @@ class MainActivity : BaseEventActivity() {
} }
registerReceiver(screenReceiver, filter) 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 val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
dm.registerDisplayListener(object : DisplayManager.DisplayListener { dm.registerDisplayListener(object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {} override fun onDisplayAdded(displayId: Int) {}
@@ -137,25 +182,79 @@ class MainActivity : BaseEventActivity() {
}, null) }, null)
} }
/** Жесты трекпада → команды просмотра (Click=пауза/плей, свайпы=±30с). */ /**
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
* голосовой ввод (безусловный вызов), Click во время прослушивания —
* завершить распознавание, DoubleClick — отмена.
*/
private fun collectTempleActions() { private fun collectTempleActions() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action -> templeActionViewModel.state.collect { action ->
val cmd = templeActionToCommand(action) when (action) {
if (cmd != null) { is TempleAction.TripleClick -> handleTripleClick()
log("gesture", "жест: ${actionName(action)} → ${cmd.command}") is TempleAction.Click -> handleClick()
GlassesApp.instance.movieController.applyMediaCommand(cmd) { is TempleAction.DoubleClick -> handleDoubleClick()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() 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 { else -> log("gesture", "жест (резерв): ${actionName(action)}")
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: * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
* mercury_system_single_tap/fling/double_click/long_press) — это и есть * mercury_system_single_tap/fling/double_click/long_press) — это и есть
@@ -211,6 +310,7 @@ class MainActivity : BaseEventActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
unregisterReceiver(screenReceiver) unregisterReceiver(screenReceiver)
unregisterReceiver(debugReceiver)
} }
/** /**
@@ -275,6 +375,29 @@ fun ConnectionScreen() {
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), 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,
)
}
}
}
} }
} }
} }
@@ -0,0 +1,119 @@
package pw.binom.viewmate.glasses.stt
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.File
import java.io.FileInputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.viewmate.glasses.log
/**
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
* filesDir/samples (отладка, см. TASK.md этап 2).
*
* Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening):
* как только очки перестали слушать, поток обрывается.
*/
class SttMicStream(
private val scope: CoroutineScope,
private val onChunk: suspend (ByteArray) -> Unit,
) {
private var job: Job? = null
fun start(filesDir: File, shouldRun: () -> Boolean) {
job?.cancel()
val recorder = runCatching {
AudioRecord(
MediaRecorder.AudioSource.MIC,
16_000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
6_400,
)
}.getOrNull()
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder?.release()
log("stt", "микрофон недоступен — отладка: PCM из файла")
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
return
}
job = scope.launch(Dispatchers.IO) {
try {
recorder.startRecording()
log("stt", "микрофон запущен (16 кГц, s16, mono)")
val buf = ByteArray(3_200)
while (isActive && shouldRun()) {
val n = recorder.read(buf, 0, buf.size)
if (n > 0) {
val chunk = if (n == buf.size) buf else buf.copyOf(n)
onChunk(chunk)
}
}
} catch (t: Throwable) {
log("stt", "микрофон: ${t.message}")
} finally {
runCatching { recorder.stop() }
recorder.release()
log("stt", "микрофон остановлен")
}
}
}
fun stop() {
job?.cancel()
job = null
}
/**
* Эмуляция живого потока: первый wav из filesDir/samples, куски по 3200 байт
* (100 мс) с паузой 200 мс, в конце — пауза 1 с (хвост тишины).
*/
private suspend fun sendWavFileAsStream(filesDir: File, shouldRun: () -> Boolean) {
val samplesDir = File(filesDir, "samples")
val wav = samplesDir.listFiles()
?.filter { it.isFile && it.extension.equals("wav", true) }
?.firstOrNull()
if (wav == null) {
log("stt", "нет samples/*.wav в ${samplesDir.absolutePath} — нечего отправлять")
return
}
log("stt", "шлю файл ${wav.name} кусками по 3200 байт")
val wavData = runCatching { WavReader.read(FileInputStream(wav)) }.getOrNull()
if (wavData == null) {
log("stt", "не удалось прочитать ${wav.name}")
return
}
val pcm = floatSamplesToPcm16(wavData.samples)
var off = 0
while (off < pcm.size) {
if (!shouldRun()) break
val end = minOf(off + 3_200, pcm.size)
onChunk(pcm.copyOfRange(off, end))
off = end
delay(200)
}
log("stt", "wav-файл отправлен целиком")
delay(1_000)
}
}
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
val out = ByteArray(samples.size * 2)
var i = 0
for (s in samples) {
val v = (s.coerceIn(-1f, 1f) * 32767f).toInt()
out[i++] = (v and 0xff).toByte()
out[i++] = ((v shr 8) and 0xff).toByte()
}
return out
}
@@ -0,0 +1,88 @@
package pw.binom.viewmate.glasses.stt
import java.io.InputStream
/**
* Читает WAV (16 кГц, mono, s16) в FloatArray [-1, 1].
* Копия ридера app-phone (этап 1) — очкам не нужна зависимость от телефона.
*/
object WavReader {
/** Прочитать WAV из потока. */
fun read(stream: InputStream): Wav {
val riff = stream.readBytes(4)
require(riff.contentEquals("RIFF".toByteArray())) { "не WAV-файл (нет RIFF)" }
stream.skip(4) // chunk size
val wave = stream.readBytes(4)
require(wave.contentEquals("WAVE".toByteArray())) { "не WAV-файл (нет WAVE)" }
var sampleRate = 0
var channels = 0
var dataBytes: ByteArray = ByteArray(0)
while (true) {
val id = stream.readBytes(4)
if (id.isEmpty()) break
val size = stream.readLittleInt()
when (String(id, Charsets.US_ASCII)) {
"fmt " -> {
val fmt = stream.readBytes(size)
val audioFormat = littleShort(fmt, 0)
require(audioFormat == 1) { "поддерживается только PCM s16 (audioFormat=$audioFormat)" }
channels = littleShort(fmt, 2)
sampleRate = littleInt(fmt, 4)
if (size > 16) stream.skip((size - 16).toLong())
}
"data" -> {
dataBytes = stream.readBytes(size)
break
}
else -> stream.skip(size.toLong())
}
}
require(sampleRate == 16000) { "ожидается 16 кГц, а тут $sampleRate" }
require(channels == 1) { "ожидается mono, а тут $channels" }
val count = dataBytes.size / 2
val samples = FloatArray(count)
for (i in 0 until count) {
val s = littleShort(dataBytes, i * 2)
samples[i] = (s / 32768f).coerceIn(-1f, 1f)
}
return Wav(sampleRate, channels, samples)
}
private fun InputStream.readBytes(n: Int): ByteArray {
val buf = ByteArray(n)
var off = 0
while (off < n) {
val r = read(buf, off, n - off)
if (r < 0) break
off += r
}
return if (off == n) buf else buf.copyOf(off)
}
private fun InputStream.readLittleInt(): Int {
val b = readBytes(4)
return littleInt(b, 0)
}
private fun littleShort(b: ByteArray, off: Int): Int {
val u = (b[off].toInt() and 0xff) or ((b[off + 1].toInt() and 0xff) shl 8)
return if (u >= 0x8000) u - 0x10000 else u
}
private fun littleInt(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xff) or
((b[off + 1].toInt() and 0xff) shl 8) or
((b[off + 2].toInt() and 0xff) shl 16) or
((b[off + 3].toInt() and 0xff) shl 24)
}
data class Wav(
val sampleRate: Int,
val channels: Int,
val samples: FloatArray,
)
@@ -0,0 +1,27 @@
package pw.binom.viewmate.glasses.stt
import kotlin.test.Test
import kotlin.test.assertEquals
class PcmConversionTest {
@Test
fun floatToPcm16RoundTrip() {
val samples = floatArrayOf(0f, 0.5f, -1f, 1f, -0.25f)
val pcm = floatSamplesToPcm16(samples)
assertEquals(samples.size * 2, pcm.size)
// 0 → 0x0000
assertEquals(0x00, pcm[0].toInt() and 0xff)
assertEquals(0x00, pcm[1].toInt() and 0xff)
// 1 → 0x7fff
assertEquals(0xff, pcm[6].toInt() and 0xff)
assertEquals(0x7f, pcm[7].toInt() and 0xff)
}
@Test
fun emptyInput() {
assertEquals(0, floatSamplesToPcm16(FloatArray(0)).size)
}
}
@@ -113,6 +113,15 @@ open class GlassesHub(
log("glasses", "очки выключились → пауза (${msg.reason})") log("glasses", "очки выключились → пауза (${msg.reason})")
state.playing = false state.playing = false
} }
is pw.binom.viewmate.core.protocol.SttAudio -> {
// Хост-отладка STT не ведёт — аудио с очков игнорируется.
log("glasses", "stt_audio: ${msg.data.size} байт (игнор)")
}
is pw.binom.viewmate.core.protocol.StopStt -> {
log("glasses", "stop_stt: cancel=${msg.cancel} (игнор — STT нет на хосте)")
}
} }
} }
@@ -28,8 +28,13 @@ import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.PlaybackPosition 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.Welcome
import pw.binom.viewmate.core.protocol.protocolJson import pw.binom.viewmate.core.protocol.protocolJson
import pw.binom.viewmate.phone.stt.SttStreamer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
const val GLASSES_WS_PATH = "/ws/glasses" 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 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) { fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit sessions[session] = Unit
_connected.value = sessions.size _connected.value = sessions.size
@@ -119,7 +136,12 @@ class GlassesHub(
/** Обработать входящее текстовое сообщение от очков. */ /** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) { 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) { when (msg) {
is Hello -> { is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome") log("glasses", "hello (app=${msg.appVersion}) → welcome")
@@ -168,6 +190,24 @@ class GlassesHub(
this.state.playing = false this.state.playing = false
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) 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) { } catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}") log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally { } finally {
hub.remove(this) hub.remove(this)
} }
@@ -1,6 +1,7 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
import android.app.Application import android.app.Application
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -17,6 +18,10 @@ import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
/** /**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState, * Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
@@ -57,6 +62,50 @@ class PhoneApp : Application() {
/** itemId, у которого автоподхват звука уже не удался — не дёргать повторно каждые 5с. */ /** itemId, у которого автоподхват звука уже не удался — не дёргать повторно каждые 5с. */
private var lastFailedItemId: String? = null private var lastFailedItemId: String? = null
private var sttLock = Any()
private var sttStreamer: SttStreamer? = null
/**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
*/
private fun ensureStt(): SttStreamer? {
synchronized(sttLock) {
sttStreamer?.let { return it }
val modelsDir = File(filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx")
val encoder = File(modelsDir, "small-encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt")
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null
}
val whisper = WhisperStt(
encoderPath = encoder.absolutePath,
decoderPath = decoder.absolutePath,
tokensPath = tokens.absolutePath,
numThreads = 4,
)
val s = SttStreamer(
stt = whisper,
vadModelPath = vadModel.absolutePath,
onPhrase = { phrase, full ->
log("stt", "фраза: $phrase")
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
},
onSilence30s = {
log("stt", "тишина 30 с — автo-отмена")
scope.launch { server.hub.broadcast(SttCancel()) }
},
)
sttStreamer = s
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
return s
}
}
/** /**
* Автоподхват звука после рестарта телефона: очки продолжают играть и шлют * Автоподхват звука после рестарта телефона: очки продолжают играть и шлют
* PlaybackPosition с itemId+audioIndex, а audioSync не активен (телефон «не в курсе»). * PlaybackPosition с itemId+audioIndex, а audioSync не активен (телефон «не в курсе»).
@@ -90,6 +139,7 @@ class PhoneApp : Application() {
server.start() server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish() nsd.publish()
server.hub.sttFactory = { ensureStt() }
val lastPositionAt = AtomicLong(0) val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false) val lastWasPlaying = AtomicBoolean(false)
@@ -161,6 +211,7 @@ class PhoneApp : Application() {
override fun onTerminate() { override fun onTerminate() {
nsd.unpublish() nsd.unpublish()
sttStreamer?.close()
server.stop() server.stop()
super.onTerminate() super.onTerminate()
} }
@@ -0,0 +1,200 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.concurrent.withLock
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
val n = pcm.size / 2
val out = FloatArray(n)
var i = 0
for (j in 0 until n) {
val lo = pcm[i].toInt() and 0xff
val hi = pcm[i + 1].toInt() and 0xff
i += 2
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
out[j] = (s / 32768f).coerceIn(-1f, 1f)
}
return out
}
/**
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
* Whisper (в отдельном потоке) → колбэк [onPhrase].
*
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
*
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s]
* (автo-отмена, вызывается один раз за сессию).
*/
class SttStreamer(
private val stt: WhisperStt,
private val vadModelPath: String,
private val onPhrase: (phrase: String, full: String) -> Unit,
private val onSilence30s: () -> Unit,
) {
companion object {
private const val SILENCE_TIMEOUT_MS = 30_000L
private const val WINDOW_SIZE = 512
}
private val lock = java.util.concurrent.locks.ReentrantLock()
private val vad: Vad = Vad(null, vadConfig())
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "stt-recognize").apply { isDaemon = true }
}
/** Накопленный текст сессии (читается из finish, пишется в executor-е). */
@Volatile
private var full = ""
/** Номер сессии: инкрементируется при reset/cancel — старые распознавания не всплывают. */
@Volatile
private var session = 0
/** Крайний срок «речи не было» (0 — сессия не активна). */
@Volatile
private var deadlineMs = 0L
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
private var pending = FloatArray(0)
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
private var lastFuture: Future<*>? = null
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
while (true) {
val deadline = deadlineMs
if (deadline > 0 && System.currentTimeMillis() > deadline) {
lock.withLock {
if (deadlineMs == deadline) {
deadlineMs = 0
resetLocked()
onSilence30s()
}
}
}
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
}
}
/** Подать кусок PCM: s16le, 16 кГц, mono (~100-200 мс). */
fun accept(pcm: ByteArray) {
lock.withLock {
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
val floats = s16leToFloat(pcm)
var i = 0
if (pending.isNotEmpty()) {
val need = WINDOW_SIZE - pending.size
val take = minOf(need, floats.size)
val combined = pending + floats.copyOfRange(0, take)
i += take
if (combined.size == WINDOW_SIZE) {
feedWindow(combined)
pending = FloatArray(0)
} else {
pending = combined
return
}
}
while (i + WINDOW_SIZE <= floats.size) {
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE))
i += WINDOW_SIZE
}
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
}
}
/**
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после
* вызова нужно [reset] (состояние сессии снимает вызывающий).
*/
fun finish(): String {
lock.withLock {
vad.flush()
while (!vad.empty()) {
val seg = vad.front()
recognizeSegment(seg.samples)
vad.pop()
}
val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
return full
}
}
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
fun reset() {
lock.withLock { resetLocked() }
}
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */
fun close() {
lock.withLock {
deadlineMs = 0
runCatching { vad.release() }
}
executor.shutdownNow()
watchdog.interrupt()
}
private fun resetLocked() {
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
runCatching { vad.flush() }
while (!vad.empty()) vad.pop()
session++
full = ""
pending = FloatArray(0)
deadlineMs = 0
}
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
private fun feedWindow(window: FloatArray) {
vad.acceptWaveform(window)
while (!vad.empty()) {
val seg = vad.front()
// Копия: массив из нативного VAD может переиспользоваться после pop.
recognizeSegment(seg.samples.copyOf())
vad.pop()
}
}
/** Распознать сегмент в executor-е: обновить full и позвать onPhrase. */
private fun recognizeSegment(samples: FloatArray) {
val sessionId = session
lastFuture = executor.submit {
if (sessionId != session) return@submit
val phrase = runCatching { stt.recognize(samples) }.getOrDefault("")
if (phrase.isBlank()) return@submit
val newFull = if (full.isEmpty()) phrase else "$full $phrase"
full = newFull
deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
onPhrase(phrase, newFull)
}
}
private fun vadConfig(): VadModelConfig = VadModelConfig(
sileroVadModelConfig = SileroVadModelConfig(
model = vadModelPath,
threshold = 0.5f,
minSilenceDuration = 0.25f,
minSpeechDuration = 0.25f,
windowSize = WINDOW_SIZE,
maxSpeechDuration = 20f,
),
sampleRate = 16000,
numThreads = 1,
provider = "cpu",
)
}
@@ -0,0 +1,31 @@
package pw.binom.viewmate.phone.stt
import kotlin.test.Test
import kotlin.test.assertEquals
class S16ToFloatTest {
@Test
fun convertsSignedSamples() {
val pcm = byteArrayOf(0x00, 0x00, 0xff.toByte(), 0x7f, 0x00, 0x80.toByte())
val out = s16leToFloat(pcm)
assertEquals(3, out.size)
assertEquals(0f, out[0], 1e-6f) // 0
assertEquals(32767f / 32768f, out[1], 1e-6f) // 0x7fff
assertEquals(-32768f / 32768f, out[2], 1e-6f) // 0x8000
}
@Test
fun minMaxSamples() {
val pcm = byteArrayOf(0xff.toByte(), 0x7f, 0x00, 0x80.toByte())
val out = s16leToFloat(pcm)
assertEquals(2, out.size)
assertEquals(32767f / 32768f, out[0], 1e-6f)
assertEquals(-1f, out[1], 1e-6f)
}
@Test
fun emptyInput() {
assertEquals(0, s16leToFloat(ByteArray(0)).size)
}
}
@@ -80,3 +80,18 @@ data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlas
@Serializable @Serializable
@SerialName("set_scale") @SerialName("set_scale")
data class SetScale(val scale: Float) : HostToGlasses 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 @Serializable
@SerialName("hello") @SerialName("hello")
data class Hello(val appVersion: String = "0.1.0") : GlassesToHost 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 val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("gesture", obj["type"]!!.jsonPrimitive.content) 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 val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("set_mode", obj["type"]!!.jsonPrimitive.content) 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)
}
} }