Files
agentik-desktop/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/RealSpeechIT.kt
T

95 lines
4.5 KiB
Kotlin

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-чанка")
}
}