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)
+ }
}