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