From 1304c6d9212fe3e49c34fcdfa57af9e1515ed245 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 04:12:28 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=B2=D0=B2=D0=BE=D0=B4=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=D1=85=20=E2=80=94=20TripleClick=20+=20?= =?UTF-8?q?PCM=20=E2=86=92=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=20?= =?UTF-8?q?=E2=86=92=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Протокол: 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 — зелёные. --- app-glasses/src/main/AndroidManifest.xml | 1 + .../pw/binom/viewmate/glasses/GlassesApp.kt | 6 + .../binom/viewmate/glasses/HostConnection.kt | 18 ++ .../pw/binom/viewmate/glasses/MainActivity.kt | 139 +++++++++++- .../viewmate/glasses/stt/SttMicStream.kt | 119 +++++++++++ .../binom/viewmate/glasses/stt/WavReader.kt | 88 ++++++++ .../viewmate/glasses/stt/PcmConversionTest.kt | 27 +++ .../pw/binom/viewmate/host/GlassesHub.kt | 9 + .../pw/binom/viewmate/phone/GlassesServer.kt | 43 +++- .../pw/binom/viewmate/phone/PhoneApp.kt | 51 +++++ .../binom/viewmate/phone/stt/SttStreamer.kt | 200 ++++++++++++++++++ .../viewmate/phone/stt/S16ToFloatTest.kt | 31 +++ .../viewmate/core/protocol/HostToGlasses.kt | 15 ++ .../binom/viewmate/core/protocol/Messages.kt | 21 ++ .../core/protocol/GlassesToHostTest.kt | 29 +++ .../core/protocol/HostToGlassesTest.kt | 30 +++ 16 files changed, 818 insertions(+), 9 deletions(-) create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavReader.kt create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/PcmConversionTest.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt create mode 100644 app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/S16ToFloatTest.kt diff --git a/app-glasses/src/main/AndroidManifest.xml b/app-glasses/src/main/AndroidManifest.xml index 733063b..831e31a 100644 --- a/app-glasses/src/main/AndroidManifest.xml +++ b/app-glasses/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + addMessage("ShowText: ${msg.text}") 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 + } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index f2fef9a..ba1512a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -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, + ) + } + } + } } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt new file mode 100644 index 0000000..bb6f588 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt @@ -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 +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavReader.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavReader.kt new file mode 100644 index 0000000..f057459 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavReader.kt @@ -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, +) diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/PcmConversionTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/PcmConversionTest.kt new file mode 100644 index 0000000..8ebd1d4 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/PcmConversionTest.kt @@ -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) + } +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt index f1a2824..8459443 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt @@ -113,6 +113,15 @@ open class GlassesHub( log("glasses", "очки выключились → пауза (${msg.reason})") 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 нет на хосте)") + } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index cb9d160..ead5507 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -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) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index ce21765..a6821f4 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -1,6 +1,7 @@ package pw.binom.viewmate.phone import android.app.Application +import java.io.File import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong 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.phone.PhoneActions 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, @@ -57,6 +62,50 @@ class PhoneApp : Application() { /** itemId, у которого автоподхват звука уже не удался — не дёргать повторно каждые 5с. */ 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 не активен (телефон «не в курсе»). @@ -90,6 +139,7 @@ class PhoneApp : Application() { server.start() log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") nsd.publish() + server.hub.sttFactory = { ensureStt() } val lastPositionAt = AtomicLong(0) val lastWasPlaying = AtomicBoolean(false) @@ -161,6 +211,7 @@ class PhoneApp : Application() { override fun onTerminate() { nsd.unpublish() + sttStreamer?.close() server.stop() super.onTerminate() } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt new file mode 100644 index 0000000..2141275 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt @@ -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", + ) +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/S16ToFloatTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/S16ToFloatTest.kt new file mode 100644 index 0000000..edcd3cd --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/S16ToFloatTest.kt @@ -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) + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt index 36e96d8..1a34482 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -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 diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt index ac27bed..03a607e 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt @@ -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 diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt index f452d40..817b7be 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt @@ -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(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(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(decoded) + assertTrue(decoded.cancel) + } } diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt index 5f7c132..9cc1e4e 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -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(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(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(decoded) + } }