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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user