diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/BtGlassesTransport.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/BtGlassesTransport.kt index c6a0a49..6450428 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/BtGlassesTransport.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/BtGlassesTransport.kt @@ -42,17 +42,19 @@ class BtGlassesTransport( companion object { /** - * Runtime-пермишены Bluetooth, зависящие от версии платформы - * (TASK-transport.md п.3, п.5: Android 12+ / до 12): + * Runtime-пермишены, зависящие от версии платформы + * (TASK-transport.md п.3, п.5: Android 12+ / до 12) + RECORD_AUDIO: + * микрофон локального STT — без него AudioRecord не стартует и сессия + * молча падает в файловую эмуляцию (пустой буфер). * 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery), * ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме). */ fun runtimePermissions(): Array = - if (Build.VERSION.SDK_INT >= 31) { + (if (Build.VERSION.SDK_INT >= 31) { arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) } else { arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN) - } + }) + Manifest.permission.RECORD_AUDIO @SuppressLint("MissingPermission") private fun hasBtPermissions(ctx: Context): Boolean = diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 1975ce1..d6a5be5 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -11,6 +11,9 @@ import kotlinx.coroutines.CoroutineScope import pw.binom.viewmate.core.log.BatchingLogCollector import pw.binom.viewmate.core.log.FileLogSpool import pw.binom.viewmate.core.log.SystemClock +import pw.binom.viewmate.glasses.stt.GlassesAsr +import pw.binom.viewmate.glasses.stt.GlassesSttSession +import pw.binom.viewmate.glasses.stt.SttModelDownloader /** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */ data class ChatEntry(val role: String, val text: String) @@ -51,6 +54,19 @@ class GlassesApp : Application() { lateinit var downloadManager: GlassesDownloadManager private set + /** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */ + lateinit var sttModel: SttModelDownloader + private set + + /** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */ + lateinit var asr: GlassesAsr + private set + + /** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */ + lateinit var sttSession: GlassesSttSession + private set + + /** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */ lateinit var logCollector: BatchingLogCollector private set @@ -157,6 +173,21 @@ class GlassesApp : Application() { attachLogCollector(logCollector) pw.binom.viewmate.core.logCollector = logCollector log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)") + + // Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range). + sttModel = SttModelDownloader(File(filesDir, "models"), bgScope) + sttModel.ensure() + + // Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике + // (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity. + asr = GlassesAsr(File(filesDir, "models")) + sttSession = GlassesSttSession( + filesDir = filesDir, + scope = bgScope, + recognizer = { asr.recognize(it) }, + modelReady = { sttModel.status.value.ready }, + onResult = { text, auto -> mainActivity?.onSttResult(text, auto) }, + ) } companion object { 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 6033a9b..4012ec6 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 @@ -48,15 +48,13 @@ 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.core.protocol.SttText import pw.binom.viewmate.glasses.gestures.AssistantState import pw.binom.viewmate.glasses.gestures.GestureDecision import pw.binom.viewmate.glasses.gestures.actionName import pw.binom.viewmate.glasses.gestures.assistantMode import pw.binom.viewmate.glasses.gestures.gestureDecision import pw.binom.viewmate.glasses.status.StatusPanelCompose -import pw.binom.viewmate.glasses.stt.SttMicStream import pw.binom.viewmate.glasses.ui.AssistantOverlay import pw.binom.viewmate.glasses.ui.Binocular import pw.binom.viewmate.glasses.ui.BinocularScreenSide @@ -70,11 +68,6 @@ import pw.binom.viewmate.glasses.ui.panelOffsetX 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` @@ -265,7 +258,7 @@ class MainActivity : BaseEventActivity() { app.sttText.value = "" app.assistantText.value = "" app.answerScrollDelta.value = 0f - sttMic.start(filesDir) { app.listening.value } + app.sttSession.start() } /** Click (кино): плей/пауза — как сегодня. */ @@ -276,15 +269,13 @@ class MainActivity : BaseEventActivity() { } } - /** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */ + /** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */ private fun sendPhrase() { - log("gesture", "Click → отправить фразу") - sttMic.stop() - GlassesApp.instance.listening.value = false - GlassesApp.instance.thinking.value = true - lifecycleScope.launch { - GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false)) - } + log("gesture", "Click → отправить фразу (распознавание локально на очках)") + val app = GlassesApp.instance + app.listening.value = false + app.thinking.value = true + app.sttSession.send() } /** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */ @@ -296,7 +287,7 @@ class MainActivity : BaseEventActivity() { app.thinking.value = false app.answerScrollDelta.value = 0f app.listening.value = true - sttMic.start(filesDir) { app.listening.value } + app.sttSession.start() } /** @@ -308,7 +299,7 @@ class MainActivity : BaseEventActivity() { val app = GlassesApp.instance app.thinking.value = false app.listening.value = true - sttMic.start(filesDir) { app.listening.value } + app.sttSession.start() log("stt", "возврат в «слушаю» (SttCancel)") } @@ -321,13 +312,38 @@ class MainActivity : BaseEventActivity() { private fun cancelDictation() { log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)") val app = GlassesApp.instance - sttMic.stop() + app.sttSession.cancel() app.sttText.value = "" app.thinking.value = false app.listening.value = true - sttMic.start(filesDir) { app.listening.value } - lifecycleScope.launch { - GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true)) + app.sttSession.start() + } + + /** + * Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке). + * [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] — + * сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся + * слушать, пустой после лимита — выход из режима. + */ + fun onSttResult(text: String, auto: Boolean) { + val app = GlassesApp.instance + app.sttText.value = text + when { + text.isNotBlank() -> { + app.listening.value = false + app.thinking.value = true + log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон") + lifecycleScope.launch { + app.hostConnection.sendToHost(SttText(text)) + } + } + auto -> finishAssistantMode("тишина 30с (очки) — выход из режима") + else -> { + log("stt", "клик без голоса — остаюсь в «слушаю»") + app.thinking.value = false + app.listening.value = true + app.sttSession.start() + } } } @@ -340,10 +356,10 @@ class MainActivity : BaseEventActivity() { */ fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима") - /** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */ + /** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */ private fun finishAssistantMode(reason: String) { log("gesture", reason) - sttMic.stop() + GlassesApp.instance.sttSession.stop() val app = GlassesApp.instance app.listening.value = false app.thinking.value = false diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt index 09e41ff..184baa1 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt @@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses.gestures import com.rayneo.arsdk.android.touch.TempleAction import pw.binom.viewmate.core.MediaCommand +import pw.binom.viewmate.glasses.stt.SttModelStatus /** * Режимы общения с ассистентом (матрица TASK-gestures-modes.md): @@ -159,6 +160,19 @@ fun overlayHeader(state: AssistantState): String? = when { else -> state.sttText.takeIf { it.isNotBlank() } } +/** + * Строка статуса модели Qwen3 для оверлея (показывается, когда модель не READY и + * микрофон открыт — распознавание гарантированно пустое): null — модель готова + * (ничего не показывать); DOWNLOADING — «качается N%»; MISSING — не скачана; + * FAILED — не скачалась (пойдут авто-повторы). Чистая функция — юнит-тесты без Android. + */ +fun modelStatusLine(status: SttModelStatus): String? = when (status.phase) { + SttModelStatus.Phase.READY -> null + SttModelStatus.Phase.DOWNLOADING -> "модель качается: ${status.percent}%" + SttModelStatus.Phase.MISSING -> "модель не скачана — распознавание недоступно" + SttModelStatus.Phase.FAILED -> "модель не скачалась — повторю автоматически" +} + /** * Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll]. * [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue]. diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesAsr.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesAsr.kt new file mode 100644 index 0000000..0d72d6c --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesAsr.kt @@ -0,0 +1,41 @@ +package pw.binom.viewmate.glasses.stt + +import java.io.File +import pw.binom.asr.qwen3.Qwen3Config +import pw.binom.asr.qwen3.createQwen3Sherpa +import pw.binom.viewmate.glasses.log + +/** + * Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot, + * тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из + * [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается + * [SttModelDownloader]). + */ +class GlassesAsr( + modelsDir: File, + numThreads: Int = 4, +) { + private val modelsDir = modelsDir + private val numThreads = numThreads + + /** + * Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить + * нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем. + */ + private val recognizer by lazy { + log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})") + createQwen3Sherpa( + Qwen3Config( + convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath, + encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath, + decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath, + tokenizerDir = File(modelsDir, "tokenizer").absolutePath, + numThreads = numThreads, + ), + ) + } + + /** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */ + fun recognize(samples: FloatArray, sampleRate: Int = 16000): String = + recognizer.recognize(samples, sampleRate) +} \ No newline at end of file diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt new file mode 100644 index 0000000..510c3ed --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/GlassesSttSession.kt @@ -0,0 +1,118 @@ +package pw.binom.viewmate.glasses.stt + +import java.io.File +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pw.binom.viewmate.core.stt.s16leToFloat +import pw.binom.viewmate.core.stt.splitPhrases +import pw.binom.viewmate.core.stt.trimEdges +import pw.binom.viewmate.glasses.log + +/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */ +interface GlassesMic { + fun start(filesDir: File, shouldRun: () -> Boolean) + fun stop() +} + +/** + * Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём + * локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше + * не участвует в распознавании. UX остаётся «говори → клик → текст». + * + * Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон, + * а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках, + * на телефон уходит фактический текст (SttText). Пустой результат по клику — + * остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером); + * пусто после 30s-лимита (timeout) — автозавершение. + * + * @param onResult результат распознавания: text — фактический текст (может быть + * пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout). + */ +class GlassesSttSession( + private val filesDir: File, + private val scope: CoroutineScope, + private val recognizer: (FloatArray) -> String, + private val modelReady: () -> Boolean, + private val onResult: (text: String, auto: Boolean) -> Unit, + private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) }, +) { + /** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */ + private val sessionLimitMs = 30_000L + + private var mic: GlassesMic? = null + + /** + * [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает + * его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки. + */ + private val pcmLock = ReentrantLock() + private var pcm = ByteArray(0) + private var watchdog: Job? = null + @Volatile + private var running = false + + val isRunning: Boolean get() = running + + /** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */ + fun start() { + if (running) return + running = true + pcmLock.withLock { pcm = ByteArray(0) } + val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } } + mic = m + m.start(filesDir) { running } + watchdog = scope.launch { + delay(sessionLimitMs) + finish(auto = true) + } + log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)") + } + + /** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */ + fun send() = finish(auto = false) + + /** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */ + fun cancel() { + stopMic() + watchdog?.cancel() + pcmLock.withLock { pcm = ByteArray(0) } + log("stt", "STT-сессия очков: отмена") + } + + /** Полный стоп (выход из режима ассистента). */ + fun stop() { + running = false + stopMic() + watchdog?.cancel() + pcmLock.withLock { pcm = ByteArray(0) } + log("stt", "STT-сессия очков: закрыта") + } + + private fun finish(auto: Boolean) { + if (!running) return + running = false + stopMic() + watchdog?.cancel() + val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } } + scope.launch(Dispatchers.Default) { + val text = if (audio.isNotEmpty() && modelReady()) { + val phrases = splitPhrases(trimEdges(s16leToFloat(audio))) + phrases.joinToString(" ") { recognizer(it) } + } else { + "" + } + withContext(Dispatchers.Main) { onResult(text, auto) } + } + } + + private fun stopMic() { + mic?.stop() + mic = null + } +} \ No newline at end of file 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 index d0d9596..d0fc378 100644 --- 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 @@ -30,13 +30,13 @@ import kotlin.math.roundToInt class SttMicStream( private val scope: CoroutineScope, private val onChunk: suspend (ByteArray) -> Unit, -) { +) : GlassesMic { private var job: Job? = null /** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */ private var dump: WavWriter? = null - fun start(filesDir: File, shouldRun: () -> Boolean) { + override fun start(filesDir: File, shouldRun: () -> Boolean) { job?.cancel() val minBuffer = AudioRecord.getMinBufferSize( MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, @@ -96,7 +96,7 @@ class SttMicStream( } } - fun stop() { + override fun stop() { job?.cancel() job = null // dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла) diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttModelProvider.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttModelProvider.kt new file mode 100644 index 0000000..8ffcdaf --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttModelProvider.kt @@ -0,0 +1,235 @@ +package pw.binom.viewmate.glasses.stt + +import java.io.File +import java.security.MessageDigest +import kotlin.concurrent.Volatile +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import pw.binom.viewmate.glasses.log +import java.io.FileOutputStream +import java.util.concurrent.TimeUnit + +/** + * Модель Qwen3-ASR 0.6B int8 для очек: 4 компонента в [filesDir]/models + * (та же иерархия, что на телефоне — `PhoneApp.ensureStt`: conv/encoder/decoder + + * tokenizer/). Скачиваются с зеркала [SttModel.BASE_URL] при отсутствии локально: + * докачка с места обрыва (Range), проверка SHA-256, сквозной прогресс. + * + * Зеркало — внутренний `static.binom.pw/models/qwen3_06/` (обычная иерархия + * корневых .onnx + подкаталог tokenizer/), URL зашит константой. + */ +object SttModel { + /** Корень зеркала модели: обычные имена файлов + подкаталог tokenizer/. */ + const val BASE_URL = "https://static.binom.pw/models/qwen3_06" + + /** Иерархия модели + размер и SHA-256 каждого файла (контроль целостности). */ + internal val FILES: List = listOf( + ModelFile("conv_frontend.onnx", 44_148_281L, "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"), + ModelFile("encoder.int8.onnx", 182_491_662L, "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"), + ModelFile("decoder.int8.onnx", 755_914_231L, "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"), + ModelFile("tokenizer/merges.txt", 1_671_853L, "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"), + ModelFile("tokenizer/vocab.json", 2_776_833L, "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"), + ModelFile("tokenizer/tokenizer_config.json", 12_487L, "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"), + ) +} + +/** Один файл модели: относительный путь (в models/), размер и SHA-256. */ +internal data class ModelFile( + val relPath: String, + val sizeBytes: Long, + val sha256Hex: String, +) + +/** Быстрый «довольны ли файлом»: есть + размер совпадает (SHA проверяем на докатке). */ +internal fun isSatisfied(modelsDir: File, f: ModelFile): Boolean { + val final = File(modelsDir, f.relPath) + return final.isFile && final.length() == f.sizeBytes +} + +/** Какие файлы [files] нужны (отсутствуют или битые по размеру). */ +internal fun whichFilesNeeded(modelsDir: File, files: List): List = + files.filter { !isSatisfied(modelsDir, it) } + +/** Модель на диске полностью: все файлы [files] на месте с верным размером. */ +internal fun isModelComplete(modelsDir: File, files: List = SttModel.FILES): Boolean = + whichFilesNeeded(modelsDir, files).isEmpty() + +/** Все байты всех файлов модели (для сквозного процента). */ +internal fun modelTotalBytes(files: List = SttModel.FILES): Long = files.sumOf { it.sizeBytes } + +/** SHA-256 файла (потоком, без загрузки всего в память). */ +internal fun sha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buf = ByteArray(1 shl 16) + while (true) { + val n = input.read(buf) + if (n < 0) break + digest.update(buf, 0, n) + } + } + return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } +} + +/** Статус модели: что происходит со скачиванием (для индикатора в UI очков). */ +data class SttModelStatus( + val phase: Phase, + val percent: Int = 0, + val detail: String = "", +) { + enum class Phase { MISSING, DOWNLOADING, READY, FAILED } + + val ready: Boolean get() = phase == Phase.READY +} + +/** + * Скачивание модели Qwen3-ASR в [modelsDir] (filesDir/models). Прогресс — в [status]. + * Идиомпотентно: [ensure] не делает ничего, если модель уже на месте; иначе докачивает + * недостающие/битые файлы с Range и проверяет SHA-256. + */ +class SttModelDownloader( + private val modelsDir: File, + private val scope: CoroutineScope, + private val okHttp: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) // большие файлы, паузы в потоке + .build(), +) { + private val _status = MutableStateFlow(SttModelStatus(SttModelStatus.Phase.MISSING)) + val status: StateFlow = _status.asStateFlow() + + @Volatile + private var downloadJob: Job? = null + + /** Таймер авто-повтора после ошибки (сеть могла появиться позже — см. [scheduleRetry]). */ + @Volatile + private var retryJob: Job? = null + + init { + refresh() + } + + companion object { + /** Пауза до авто-повтора скачивания после FAILED: сеть могла появиться позже. */ + private const val RETRY_DELAY_MS = 15_000L + } + + /** Перечитать с диска: READY (готово) или MISSING (не хватает). */ + fun refresh() { + _status.value = if (isModelComplete(modelsDir)) { + SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова") + } else { + SttModelStatus(SttModelStatus.Phase.MISSING, overallPercent(), "нет локально — скачаю при старте") + } + } + + /** Скачать недостающее (idempotent; повторный вызов пока идёт — игнор). */ + fun ensure() { + if (isModelComplete(modelsDir)) { + refresh() + return + } + if (downloadJob?.isActive == true) return + downloadJob = scope.launch { + try { + val total = modelTotalBytes() + val needed = whichFilesNeeded(modelsDir, SttModel.FILES) + var done = total - needed.sumOf { it.sizeBytes } // байты уже готовых файлов + needed.forEach { f -> + currentCoroutineContext().ensureActive() + downloadOne(f) { fileDone -> + val overall = ((done + fileDone) * 100 / total).toInt().coerceIn(0, 100) + _status.value = SttModelStatus(SttModelStatus.Phase.DOWNLOADING, overall, "${f.relPath}: $overall%") + } + done += f.sizeBytes + log("stt", "модель: скачан ${f.relPath} (${f.sizeBytes} Б)") + } + _status.value = SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова") + log("stt", "модель Qwen3-ASR готова в ${modelsDir.absolutePath}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("stt", "ошибка скачивания модели: ${e.message}") + _status.value = SttModelStatus(SttModelStatus.Phase.FAILED, overallPercent(), "ошибка: ${e.message}") + scheduleRetry() + } + } + } + + /** + * Авто-повтор после [SttModelStatus.Phase.FAILED]: сеть могла появиться позже + * (Wi-Fi очков выключен при старте, DNS не резолвится — фиксированные случаи). + * Один таймер: новый провал перезапускает его; при готовности/идущем скачивании + * [ensure] — no-op, так что повторы сами завершаются. + */ + private fun scheduleRetry() { + retryJob?.cancel() + retryJob = scope.launch { + delay(RETRY_DELAY_MS) + log("stt", "модель: авто-повтор скачивания (ошибка могла быть сессионной — сеть)") + ensure() + } + } + + /** Скачать один файл: Range-докачка в .part, SHA-256, rename в финальный. onProgress — байты файла. */ + private suspend fun downloadOne(f: ModelFile, onProgress: (Long) -> Unit) { + val parent = File(modelsDir, f.relPath).parentFile + parent?.mkdirs() + val final = File(modelsDir, f.relPath) + val part = File(modelsDir, "$f.relPath.part") + val existing = if (part.exists()) part.length() else 0L + + val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath) + if (existing > 0) builder.header("Range", "bytes=$existing-") + + okHttp.newCall(builder.build()).execute().use { response -> + val code = response.code + if (code != 200 && code != 206) error("HTTP $code для ${f.relPath}") + val body = response.body + + val resumed = code == 206 && existing > 0 + val input = body.byteStream() + FileOutputStream(part, resumed).use { out -> + var done = if (resumed) existing else 0L + onProgress(done) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + currentCoroutineContext().ensureActive() + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + done += read + onProgress(done) + } + } + + if (sha256Hex(part) != f.sha256Hex) { + part.delete() + error("SHA-256 не совпал для ${f.relPath}") + } + if (final.exists()) final.delete() + if (!part.renameTo(final)) { + part.copyTo(final, overwrite = true) + part.delete() + } + } + } + + /** Сквозной процент: сколько всех байтов модели уже на диске (по размеру). */ + private fun overallPercent(): Int { + val total = modelTotalBytes() + if (total <= 0) return 0 + val have = SttModel.FILES.filter { isSatisfied(modelsDir, it) }.sumOf { it.sizeBytes } + return (have * 100 / total).toInt().coerceIn(0, 100) + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt index 08def1c..ac20c1b 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt @@ -32,6 +32,7 @@ import pw.binom.viewmate.glasses.ChatEntry import pw.binom.viewmate.glasses.GlassesApp import pw.binom.viewmate.glasses.gestures.AssistantState import pw.binom.viewmate.glasses.gestures.overlayHeader +import pw.binom.viewmate.glasses.gestures.modelStatusLine import pw.binom.viewmate.glasses.gestures.overlayVisible import pw.binom.viewmate.glasses.gestures.scrollOffset @@ -55,6 +56,10 @@ fun AssistantOverlay() { val chatHistory by app.chatHistory.collectAsState() val state = AssistantState(listening, thinking, sttText, assistantText) if (!overlayVisible(state)) return + val modelStatus by app.sttModel.status.collectAsState() + // Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание + // локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему. + val modelLine = if (state.listening) modelStatusLine(modelStatus) else null // Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды // (левый/правый глаз), поэтому scrollState и коллекторы должны существовать // в единственном экземпляре — иначе глаза скроллят по-разному. @@ -81,7 +86,7 @@ fun AssistantOverlay() { } } - overlayBox(app, state, chatHistory, scrollState) + overlayBox(app, state, chatHistory, scrollState, modelLine) } /** @@ -97,6 +102,7 @@ private fun overlayBox( state: AssistantState, chatHistory: List, scrollState: ScrollState, + modelLine: String?, ) { Binocular(scale = false) { val scale by app.scale.collectAsState() @@ -119,7 +125,7 @@ private fun overlayBox( // вертикаль (vertical) — только для видео; оверлей всегда у верха ), ) { - AssistantOverlayContent(state, chatHistory, scrollState) + AssistantOverlayContent(state, chatHistory, scrollState, modelLine) if (state.listening) { Box( @@ -140,6 +146,7 @@ private fun AssistantOverlayContent( state: AssistantState, chatHistory: List, scrollState: ScrollState, + modelLine: String?, ) { val hasHistory = chatHistory.isNotEmpty() val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank() @@ -160,6 +167,17 @@ private fun AssistantOverlayContent( style = MaterialTheme.typography.titleMedium, ) } + // Строка статуса модели (только «слушаю» + модель не READY) — объясняет, почему + // распознавание сейчас не работает и что происходит (качается / не скачалась). + if (!modelLine.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = modelLine, + color = Color(0xFFB0BEC5), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall, + ) + } if (hasHistory) { Spacer(modifier = Modifier.height(12.dp)) Column( diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt index cec5d40..9f34699 100644 --- a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt @@ -15,6 +15,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import pw.binom.viewmate.core.MediaCommand +import pw.binom.viewmate.glasses.stt.SttModelStatus /** * Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино, @@ -227,6 +228,33 @@ class AssistantGesturesTest { assertNull(overlayHeader(movie)) } + // ————— строка статуса модели ————— + + @Test + fun modelStatusLineReadyIsNull() { + assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100))) + } + + @Test + fun modelStatusLineDownloadingShowsPercent() { + assertEquals( + "модель качается: 45%", + modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)), + ) + } + + @Test + fun modelStatusLineMissingAndFailed() { + assertEquals( + "модель не скачана — распознавание недоступно", + modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)), + ) + assertEquals( + "модель не скачалась — повторю автоматически", + modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")), + ) + } + // ————— скролл ответа ————— @Test diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/SttModelProviderTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/SttModelProviderTest.kt new file mode 100644 index 0000000..5cdf20f --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/SttModelProviderTest.kt @@ -0,0 +1,86 @@ +package pw.binom.viewmate.glasses.stt + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SttModelProviderTest { + + private fun newModelDir(): File { + val dir = Files.createTempDirectory("stt-model-test").toFile() + dir.deleteOnExit() + return dir + } + + private fun smallFiles() = listOf( + ModelFile("a.onnx", 4L, "sha-a"), + ModelFile("tokenizer/tok.json", 5L, "sha-b"), + ) + + private fun write(modelDir: File, relPath: String, size: Int) { + val f = File(modelDir, relPath) + f.parentFile?.mkdirs() + f.writeBytes(ByteArray(size)) + } + + @Test + fun manifestIsSane() { + assertEquals(6, SttModel.FILES.size) + assertEquals(6, SttModel.FILES.map { it.relPath }.toSet().size, "пути дублируются") + SttModel.FILES.forEach { f -> + assertTrue(f.sha256Hex.length == 64, "некорректный SHA-256: ${f.relPath}") + assertTrue(f.sizeBytes > 0, "нулевой размер: ${f.relPath}") + } + // Иерархия 1:1 с filesDir/models телефона: 3 корневых .onnx + tokenizer/ (3 файла). + assertTrue(SttModel.FILES.any { it.relPath == "conv_frontend.onnx" }) + assertTrue(SttModel.FILES.any { it.relPath == "tokenizer/merges.txt" }) + } + + @Test + fun emptyDirNeedsAllFiles() { + val dir = newModelDir() + val files = smallFiles() + val needed = whichFilesNeeded(dir, files) + assertEquals(files, needed) + assertTrue(!isModelComplete(dir, files)) + } + + @Test + fun completeDirNeedsNothing() { + val dir = newModelDir() + val files = smallFiles() + write(dir, "a.onnx", 4) + write(dir, "tokenizer/tok.json", 5) + assertEquals(emptyList(), whichFilesNeeded(dir, files)) + assertTrue(isModelComplete(dir, files)) + } + + @Test + fun wrongSizeTreatedAsNeeded() { + val dir = newModelDir() + val files = smallFiles() + write(dir, "a.onnx", 3) // короче ожидаемых 4 + write(dir, "tokenizer/tok.json", 5) + val needed = whichFilesNeeded(dir, files) + assertEquals(listOf("a.onnx"), needed.map { it.relPath }) + } + + @Test + fun modelTotalBytesIsSum() { + assertEquals(9L, modelTotalBytes(smallFiles())) + assertTrue( + modelTotalBytes() in 900_000_000..1_000_000_000, + "сумма байт модели не в ожидаемом порядке: ${modelTotalBytes()}", + ) + } + + @Test + fun sha256HexOfKnownInput() { + val dir = newModelDir() + val f = File(dir, "known.bin") + f.writeBytes("abc".encodeToByteArray()) + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", sha256Hex(f)) + } +} 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 96fa402..e5917c4 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,6 +28,7 @@ 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.SttText import pw.binom.viewmate.core.protocol.StopStt import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.protocolJson @@ -235,6 +236,19 @@ class GlassesHub( } } } + + // Новый путь: очки распознали речь локально и прислали готовый текст — + // в LLM тем же каналом, что и StopStt(cancel=false). Old SttAudio/StopStt + // остаются фолбэком для старых очей. + is SttText -> { + val full = msg.text.trim() + if (full.isNotBlank()) { + log("stt", "STT-текст от очков (локально): $full → LLM") + onStopFullText?.invoke(full) + } else { + log("stt", "STT-текст от очков пуст — ignore") + } + } } } 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 index a903f69..b6553d5 100644 --- 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 @@ -6,169 +6,11 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.concurrent.thread import kotlin.concurrent.withLock -import kotlin.math.sqrt +import pw.binom.viewmate.core.stt.s16leToFloat +import pw.binom.viewmate.core.stt.splitPhrases +import pw.binom.viewmate.core.stt.trimEdges import pw.binom.viewmate.phone.log -/** 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 -} - -/** - * Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы. - * Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно, - * чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна - * с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам). - * - * Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются. - * Чистая функция (без Android) — покрыта JVM-тестами. - */ -internal fun trimEdges( - samples: FloatArray, - threshold: Float = 0.01f, - windowSize: Int = 320, - padWindows: Int = 15, -): FloatArray { - if (samples.isEmpty()) return samples - var first = -1 - var lastEnd = -1 - var idx = 0 - while (idx < samples.size) { - val end = minOf(idx + windowSize, samples.size) - var sum = 0.0 - for (k in idx until end) sum += samples[k] * samples[k].toDouble() - val rms = sqrt(sum / (end - idx)).toFloat() - if (rms >= threshold) { - if (first == -1) first = idx - lastEnd = end - } - idx = end - } - if (first == -1) return FloatArray(0) - val pad = padWindows * windowSize - val start = maxOf(0, first - pad) - val stop = minOf(samples.size, lastEnd + pad) - return samples.copyOfRange(start, stop) -} - -/** - * Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон). - * Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с). - * Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) — - * НЕ менять без перепроверки на тех же дампах. - * - * Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01 - * как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза = - * подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы. - * Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с - * каждой стороны (clamp к границам массива). Фраза короче - * [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная - * — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по - * границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы - * < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами. - */ -internal fun splitPhrases( - samples: FloatArray, - threshold: Float = 0.005f, - windowSize: Int = 320, // 20 мс @ 16 кГц - minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с - minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней - maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с - padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы -): List { - if (samples.isEmpty()) return emptyList() - val winCount = (samples.size + windowSize - 1) / windowSize - val isSound = BooleanArray(winCount) - var anySound = false - for (w in 0 until winCount) { - val s = w * windowSize - val e = minOf(s + windowSize, samples.size) - var sum = 0.0 - for (k in s until e) sum += samples[k] * samples[k].toDouble() - if (sqrt(sum / (e - s)).toFloat() >= threshold) { - isSound[w] = true - anySound = true - } - } - if (!anySound) return emptyList() - - // Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы - // разграничены паузами из ≥ minSilenceWindows подряд тихих окон. - class Phrase(var start: Int, var end: Int) - - val phrases = mutableListOf() - var pos = 0 - while (pos < winCount) { - var sil = 0 - while (pos + sil < winCount && !isSound[pos + sil]) sil++ - if (pos + sil >= winCount) break - val start = pos + sil - var end = start - var continuePhrase = true - while (continuePhrase) { - var run = 0 - while (end + run < winCount && isSound[end + run]) run++ - end += run - if (end >= winCount) { continuePhrase = false; break } - var sil2 = 0 - while (end + sil2 < winCount && !isSound[end + sil2]) sil2++ - if (sil2 >= minSilenceWindows) { - continuePhrase = false - } else { - end += sil2 - } - } - phrases.add(Phrase(start, end)) - pos = end - } - - // Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений). - while (phrases.size > 1) { - val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows } - if (idx < 0) break - val other = if (idx == 0) idx + 1 else idx - 1 - val lo = minOf(idx, other) - val hi = maxOf(idx, other) - val merged = Phrase( - minOf(phrases[lo].start, phrases[hi].start), - maxOf(phrases[lo].end, phrases[hi].end), - ) - phrases.removeAt(hi) - phrases.removeAt(lo) - phrases.add(lo, merged) - } - - val pad = padWindows * windowSize - val out = mutableListOf() - for (p in phrases) { - val s = maxOf(0, p.start * windowSize - pad) - val e = minOf(samples.size, p.end * windowSize + pad) - if (e - s <= maxPhraseSamples) { - out += samples.copyOfRange(s, e) - } else { - val chunk = (maxPhraseSamples / windowSize) * windowSize - var cs = s - while (cs < e) { - val ce = minOf(e, cs + chunk) - out += samples.copyOfRange(cs, ce) - cs = ce - } - } - } - return out -} - /** * Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз — * распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt index ea3cc25..06248a5 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt @@ -3,6 +3,7 @@ package pw.binom.viewmate.phone.stt import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import pw.binom.viewmate.core.stt.splitPhrases /** * Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на 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 index edcd3cd..3442eca 100644 --- 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 @@ -2,6 +2,7 @@ package pw.binom.viewmate.phone.stt import kotlin.test.Test import kotlin.test.assertEquals +import pw.binom.viewmate.core.stt.s16leToFloat class S16ToFloatTest { diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/TrimEdgesTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/TrimEdgesTest.kt index 8945908..6242703 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/TrimEdgesTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/TrimEdgesTest.kt @@ -3,6 +3,7 @@ package pw.binom.viewmate.phone.stt import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import pw.binom.viewmate.core.stt.trimEdges /** * Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается, 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 71efcea..3c76071 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 @@ -109,6 +109,16 @@ data class SttAudio(val data: ByteArray) : GlassesToHost { @SerialName("stop_stt") data class StopStt(val cancel: Boolean) : GlassesToHost +/** + * Готовый распознанный текст (очки → телефон): очки сами распознали речь локально + * (Qwen3-ASR) и шлют фактический текст — телефон маршрутизирует его в LLM. + * Замена пути SttAudio/StopStt (там распознавал телефон); тот остаётся как фолбэк + * для старых очей. Пустой текст очки не шлют. + */ +@Serializable +@SerialName("stt_text") +data class SttText(val text: String) : GlassesToHost + /** * Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему * коллектору и пересылает на сервер вместе со своими лог-записями; diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/stt/AudioPrep.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/stt/AudioPrep.kt new file mode 100644 index 0000000..c540b64 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/stt/AudioPrep.kt @@ -0,0 +1,171 @@ +package pw.binom.viewmate.core.stt + +import kotlin.math.sqrt + +/** + * Чистые (без Android) преобразования PCM перед офлайн-распознаванием — общие + * для телефона и очков. Телефон накапливает и распознаёт на себе (фолбэк); очки + * распозновывают локально и шлют готовый текст (новый путь). Тело — дословно из + * SttStreamer (app-phone): числа выверены на дампах микрофона очков (×3, 16 кГц), + * НЕ менять без перепроверки. + */ + +/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */ +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 +} + +/** + * Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы. + * Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно, + * чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна + * с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам). + * + * Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются. + * Чистая функция (без Android) — покрыта JVM-тестами. + */ +fun trimEdges( + samples: FloatArray, + threshold: Float = 0.01f, + windowSize: Int = 320, + padWindows: Int = 15, +): FloatArray { + if (samples.isEmpty()) return samples + var first = -1 + var lastEnd = -1 + var idx = 0 + while (idx < samples.size) { + val end = minOf(idx + windowSize, samples.size) + var sum = 0.0 + for (k in idx until end) sum += samples[k] * samples[k].toDouble() + val rms = sqrt(sum / (end - idx)).toFloat() + if (rms >= threshold) { + if (first == -1) first = idx + lastEnd = end + } + idx = end + } + if (first == -1) return FloatArray(0) + val pad = padWindows * windowSize + val start = maxOf(0, first - pad) + val stop = minOf(samples.size, lastEnd + pad) + return samples.copyOfRange(start, stop) +} + +/** + * Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон). + * Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с). + * Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) — + * НЕ менять без перепроверки на тех же дампах. + * + * Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01 + * как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза = + * подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы. + * Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с + * каждой стороны (clamp к границам массива). Фраза короче + * [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная + * — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по + * границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы + * < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами. + */ +fun splitPhrases( + samples: FloatArray, + threshold: Float = 0.005f, + windowSize: Int = 320, // 20 мс @ 16 кГц + minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с + minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней + maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с + padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы +): List { + if (samples.isEmpty()) return emptyList() + val winCount = (samples.size + windowSize - 1) / windowSize + val isSound = BooleanArray(winCount) + var anySound = false + for (w in 0 until winCount) { + val s = w * windowSize + val e = minOf(s + windowSize, samples.size) + var sum = 0.0 + for (k in s until e) sum += samples[k] * samples[k].toDouble() + if (sqrt(sum / (e - s)).toFloat() >= threshold) { + isSound[w] = true + anySound = true + } + } + if (!anySound) return emptyList() + + // Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы + // разграничены паузами из ≥ minSilenceWindows подряд тихих окон. + class Phrase(var start: Int, var end: Int) + + val phrases = mutableListOf() + var pos = 0 + while (pos < winCount) { + var sil = 0 + while (pos + sil < winCount && !isSound[pos + sil]) sil++ + if (pos + sil >= winCount) break + val start = pos + sil + var end = start + var continuePhrase = true + while (continuePhrase) { + var run = 0 + while (end + run < winCount && isSound[end + run]) run++ + end += run + if (end >= winCount) { continuePhrase = false; break } + var sil2 = 0 + while (end + sil2 < winCount && !isSound[end + sil2]) sil2++ + if (sil2 >= minSilenceWindows) { + continuePhrase = false + } else { + end += sil2 + } + } + phrases.add(Phrase(start, end)) + pos = end + } + + // Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений). + while (phrases.size > 1) { + val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows } + if (idx < 0) break + val other = if (idx == 0) idx + 1 else idx - 1 + val lo = minOf(idx, other) + val hi = maxOf(idx, other) + val merged = Phrase( + minOf(phrases[lo].start, phrases[hi].start), + maxOf(phrases[lo].end, phrases[hi].end), + ) + phrases.removeAt(hi) + phrases.removeAt(lo) + phrases.add(lo, merged) + } + + val pad = padWindows * windowSize + val out = mutableListOf() + for (p in phrases) { + val s = maxOf(0, p.start * windowSize - pad) + val e = minOf(samples.size, p.end * windowSize + pad) + if (e - s <= maxPhraseSamples) { + out += samples.copyOfRange(s, e) + } else { + val chunk = (maxPhraseSamples / windowSize) * windowSize + var cs = s + while (cs < e) { + val ce = minOf(e, cs + chunk) + out += samples.copyOfRange(cs, ce) + cs = ce + } + } + } + return out +} 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 60510d3..b25b1bc 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 @@ -213,4 +213,14 @@ class GlassesToHostTest { assertIs(decoded) assertTrue(decoded.cancel) } + + @Test + fun sttTextRoundTrip() { + val msg: GlassesToHost = SttText(text = "включи сериал брат 2") + val text = json.encodeToString(GlassesToHost.serializer(), msg) + assertEquals("""{"type":"stt_text","text":"включи сериал брат 2"}""", text) + val decoded = json.decodeFromString(GlassesToHost.serializer(), text) + assertIs(decoded) + assertEquals("включи сериал брат 2", decoded.text) + } }