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:
@@ -4,6 +4,7 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<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.NEARBY_WIFI_DEVICES"
|
||||
|
||||
@@ -36,6 +36,12 @@ class GlassesApp : Application() {
|
||||
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
|
||||
val scale = MutableStateFlow(1f)
|
||||
|
||||
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
|
||||
val listening = MutableStateFlow(false)
|
||||
|
||||
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
|
||||
val sttText = MutableStateFlow("")
|
||||
|
||||
/** Применить и сохранить масштаб. */
|
||||
fun applyScale(value: Float) {
|
||||
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.SetScale
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -267,6 +270,21 @@ class HostConnection(
|
||||
is ShowText -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user