95 lines
4.5 KiB
Kotlin
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-чанка")
|
|
}
|
|
}
|