package pw.binom.agentik.desktop.voice import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.runComposeUiTest import pw.binom.agentik.desktop.ui.renderMarkdownSegments import pw.binom.agentik.desktop.ui.theme.AgentikTheme import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme import pw.binom.agentik.desktop.ui.theme.toAgentikTheme import kotlin.test.Test import kotlin.test.assertTrue /** * Настоящая речь через весь продуктовый путь, в том порядке, в каком это * происходит в приложении: * * 1. [VoiceNative.warmUp] — нативный ASR (Vosk + ONNX VAD) прогревается ДО skia; * 2. инициализируется skiko (оффскрин-рендер Compose с текстом); * 3. создаётся **новый** [VoskVoiceRecognizer] (свежая модель Vosk + свежий VAD) * уже после skia и распознаёт реальный русский сэмпл. * * Шаг 3 — это ровно то, что делает кнопка микрофона в приложении (каждая запись * создаёт свой распознаватель). Без шага 1 он валит JVM с SIGSEGV: см. * [VoiceNative]. Реплика в `raz-dva-tri.wav` — «раз два три четыре пять» * (16 кГц, моно, s16le). */ class RealSpeechIT { @OptIn(ExperimentalTestApi::class) @Test fun `speech is recognized after the compose window has been initialized`() { // 1. как в main(): прогреваем нативный ASR до skiko. assertTrue(VoiceNative.warmUp(), "нативный ASR должен прогреться до skiko") assertTrue(VoiceNative.available) // 2. skiko/Compose инициализируется и реально рисует (как окно приложения). runComposeUiTest { setContent { ProvideAgentikTheme("dark".toAgentikTheme()) { Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) { renderMarkdownSegments( content = "проверка: **окно** уже отрисовано", ) } } } } // 3. обычный путь распознавания — уже после skiko. val recognizer = VoskVoiceRecognizer() try { val pcm = loadPcm("/voice/raz-dva-tri.wav") assertTrue(pcm.size > VOICE_SAMPLE_RATE, "сэмпл должен быть длиннее секунды") var offset = 0 val chunk = VOICE_SAMPLE_RATE / 10 * 2 var maxPartial = "" while (offset < pcm.size) { val n = minOf(chunk, pcm.size - offset) recognizer.feed(pcm.copyOfRange(offset, offset + n)) offset += n recognizer.partial()?.let { if (it.length > maxPartial.length) maxPartial = it } } val final = recognizer.finish() assertTrue(recognizer.speechDetected, "VAD должен увидеть речь (partials: '$maxPartial')") assertTrue( final.contains("раз") && final.contains("пять"), "ожидали услышать «раз два три четыре пять», получили: '$final' (partials: '$maxPartial')", ) } finally { recognizer.close() } } /** PCM-данные из WAV: пропускаем RIFF-заголовок до чанка `data`. */ private fun loadPcm(resource: String): ByteArray { val bytes = javaClass.getResourceAsStream(resource)!!.readBytes() var i = 12 while (i + 8 <= bytes.size) { val id = String(bytes, i, 4, Charsets.US_ASCII) val size = (bytes[i + 4].toInt() and 0xFF) or ((bytes[i + 5].toInt() and 0xFF) shl 8) or ((bytes[i + 6].toInt() and 0xFF) shl 16) or ((bytes[i + 7].toInt() and 0xFF) shl 24) if (id == "data") return bytes.copyOfRange(i + 8, minOf(i + 8 + size, bytes.size)) i += 8 + size + (size and 1) } error("в $resource нет data-чанка") } }