Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты
- settings/: settings.json (агенты, тема, clientId), атомарная запись - persistence/: ConversationMetaRepository — группы, lastSeen, превью, watermark синхронизации (synced_at через PRAGMA table_info) - session/: AgentRegistry (параллельное подключение, деградация в офлайн), AgentConnection (журнал + мета + живой список диалогов conversationsFlow, refresh по SSE-потоку), ChatSession (history + backfill + live + preview, дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn - UI: рейка агентов, список диалогов (живое обновление), чат со стримингом, композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос), окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы - voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке; VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте - markdown/: вендоренный рендер из ai/assistent - ошибки в баннер через AppState.reportError (CancellationException игнорируется) - тесты 103 (реальная SQLite + фейковый транспорт); против живого агента — RealServerIT, RealAppStateIT, RealSpeechIT - LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package pw.binom.agentik.desktop.voice
|
||||
|
||||
import pw.binom.voice.vad.VadInstance
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Чистые преобразования звука: s16le → float и нарезка на окна VAD.
|
||||
* Без нативных библиотек — только арифметика.
|
||||
*/
|
||||
class AudioTest {
|
||||
|
||||
@Test
|
||||
fun `s16le bytes become floats in minus one to one`() {
|
||||
val pcm = byteArrayOf(
|
||||
0x00, 0x00, // 0
|
||||
0xFF.toByte(), 0x7F, // +32767
|
||||
0x00, 0x80.toByte(), // -32768
|
||||
0x00, 0x40, // +16384
|
||||
)
|
||||
val floats = pcm16ToFloats(pcm)
|
||||
|
||||
assertEquals(4, floats.size)
|
||||
assertEquals(0f, floats[0])
|
||||
assertEquals(32767f / 32768f, floats[1])
|
||||
assertEquals(-1f, floats[2])
|
||||
assertEquals(0.5f, floats[3])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative values keep their sign`() {
|
||||
// -1 в s16le = 0xFFFF
|
||||
val floats = pcm16ToFloats(byteArrayOf(0xFF.toByte(), 0xFF.toByte()))
|
||||
assertEquals(1, floats.size)
|
||||
assertEquals(-1f / 32768f, floats[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offset and length select a slice`() {
|
||||
val pcm = byteArrayOf(
|
||||
0x11, 0x11, // пропустить
|
||||
0x00, 0x00, // 0
|
||||
0x00, 0x40, // 0.5
|
||||
0x22, 0x22, // пропустить
|
||||
)
|
||||
val floats = pcm16ToFloats(pcm, offset = 2, length = 4)
|
||||
|
||||
assertEquals(2, floats.size)
|
||||
assertEquals(0f, floats[0])
|
||||
assertEquals(0.5f, floats[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `odd trailing byte is dropped`() {
|
||||
val floats = pcm16ToFloats(byteArrayOf(0x00, 0x40, 0x7F))
|
||||
assertEquals(1, floats.size)
|
||||
assertEquals(0.5f, floats[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `windower emits exact windows and keeps the remainder`() {
|
||||
val windows = mutableListOf<List<Float>>()
|
||||
val windower = PcmWindower(windowSize = 4) { windows.add(it.toList()) }
|
||||
|
||||
// 4 → окно
|
||||
windower.push(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
assertEquals(1, windows.size)
|
||||
assertEquals(listOf(1f, 2f, 3f, 4f), windows[0])
|
||||
assertEquals(0, windower.pending)
|
||||
|
||||
// 2 → ждём
|
||||
windower.push(floatArrayOf(5f, 6f))
|
||||
assertEquals(1, windows.size)
|
||||
assertEquals(2, windower.pending)
|
||||
|
||||
// 5 → добираем окно (5,6,7,8), остаётся (9,10,11)
|
||||
windower.push(floatArrayOf(7f, 8f, 9f, 10f, 11f))
|
||||
assertEquals(2, windows.size)
|
||||
assertEquals(listOf(5f, 6f, 7f, 8f), windows[1])
|
||||
assertEquals(3, windower.pending)
|
||||
|
||||
// 12 → добираем (9,10,11,12) и снова пусто
|
||||
windower.push(floatArrayOf(12f))
|
||||
assertEquals(3, windows.size)
|
||||
assertEquals(listOf(9f, 10f, 11f, 12f), windows[2])
|
||||
assertEquals(0, windower.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `windower gives independent copies not aliased buffer`() {
|
||||
val windows = mutableListOf<FloatArray>()
|
||||
val windower = PcmWindower(windowSize = 2) { windows.add(it) }
|
||||
|
||||
windower.push(floatArrayOf(1f, 2f))
|
||||
windower.push(floatArrayOf(3f, 4f))
|
||||
|
||||
assertEquals(2, windows.size)
|
||||
// Если бы отдавали внутренний буфер, первое окно уже было бы (3,4).
|
||||
assertEquals(listOf(1f, 2f), windows[0].toList())
|
||||
assertEquals(listOf(3f, 4f), windows[1].toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `windower accepts a large single push`() {
|
||||
val windows = mutableListOf<FloatArray>()
|
||||
val windower = PcmWindower(windowSize = 512) { windows.add(it) }
|
||||
|
||||
windower.push(FloatArray(1600) { it.toFloat() })
|
||||
|
||||
assertEquals(3, windows.size)
|
||||
assertEquals(1600 - 3 * 512, windower.pending)
|
||||
assertEquals(512, windows[0].size)
|
||||
assertEquals(0f, windows[0][0])
|
||||
assertEquals(511f, windows[0][511])
|
||||
// Второе окно начинается там, где кончилось первое.
|
||||
assertEquals(512f, windows[1][0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty push does nothing`() {
|
||||
val windows = mutableListOf<FloatArray>()
|
||||
val windower = PcmWindower(windowSize = 4) { windows.add(it) }
|
||||
windower.push(FloatArray(0))
|
||||
assertTrue(windows.isEmpty())
|
||||
assertEquals(0, windower.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vad model reports low probability for silence`() {
|
||||
// Реальная загрузка silero_vad.onnx из артефакта `vad-jvm` (нативная
|
||||
// onnxruntime). Тишина не должна выглядеть речью.
|
||||
VadInstance(VOICE_SAMPLE_RATE).use { vad ->
|
||||
assertEquals(512, vad.windowSizeSamples)
|
||||
val silenceProbability = vad.processingWindow(FloatArray(vad.windowSizeSamples))
|
||||
assertNotNull(silenceProbability)
|
||||
assertTrue(
|
||||
silenceProbability < VoskVoiceRecognizer.VAD_SPEECH_THRESHOLD,
|
||||
"тишина дала вероятность речи $silenceProbability",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import pw.binom.agentik.desktop.ui.renderMarkdownSegments
|
||||
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
|
||||
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 {
|
||||
AgentikTheme("dark") {
|
||||
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) {
|
||||
renderMarkdownSegments(
|
||||
content = "проверка: **окно** уже отрисовано",
|
||||
textColor = AgentikTheme.colors.text,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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-чанка")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package pw.binom.agentik.desktop.voice
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.fail
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Состояния и колбэки [VoiceController] на фейковых источнике аудио и
|
||||
* распознавателе — без микрофона и без нативных библиотек.
|
||||
*
|
||||
* Время настоящее (`runBlocking` + ожидание состояния), а не `runTest` с
|
||||
* виртуальным: контроллер считает на выделенном потоке `Dispatchers.Default`.
|
||||
*/
|
||||
class VoiceControllerTest {
|
||||
|
||||
private val chunk = ByteArray(4) { it.toByte() }
|
||||
|
||||
private fun finiteMic(vararg chunks: ByteArray): MicSource =
|
||||
MicSource { _, _ -> flow { chunks.forEach { emit(it) } } }
|
||||
|
||||
private fun endlessMic(chunk: ByteArray = ByteArray(4) { 1 }): MicSource = MicSource { _, _ ->
|
||||
flow {
|
||||
while (true) {
|
||||
emit(chunk)
|
||||
delay(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeRecognizer(
|
||||
private val partials: List<String> = emptyList(),
|
||||
private val finalText: String = "",
|
||||
override val speechDetected: Boolean = true,
|
||||
) : VoiceRecognizer {
|
||||
val fed = mutableListOf<ByteArray>()
|
||||
var finished = false
|
||||
var closed = false
|
||||
private var partialCalls = 0
|
||||
|
||||
override fun feed(pcm: ByteArray) {
|
||||
fed += pcm
|
||||
}
|
||||
|
||||
override fun partial(): String? {
|
||||
if (partials.isEmpty()) return null
|
||||
return partials[minOf(partialCalls++, partials.size - 1)]
|
||||
}
|
||||
|
||||
override fun finish(): String {
|
||||
finished = true
|
||||
return finalText
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
|
||||
private class Recorder {
|
||||
val states = mutableListOf<VoiceState>()
|
||||
val partials = mutableListOf<String>()
|
||||
val finals = mutableListOf<String>()
|
||||
val errors = mutableListOf<String>()
|
||||
}
|
||||
|
||||
private fun controller(
|
||||
scope: CoroutineScope,
|
||||
mic: MicSource,
|
||||
recognizer: VoiceRecognizer,
|
||||
rec: Recorder,
|
||||
) = VoiceController(
|
||||
scope = scope,
|
||||
recognizers = VoiceRecognizerFactory { recognizer },
|
||||
mic = mic,
|
||||
onState = { rec.states += it },
|
||||
onPartial = { rec.partials += it },
|
||||
onFinal = { rec.finals += it },
|
||||
onError = { rec.errors += it },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `stop delivers final text and closes recognizer`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет")
|
||||
val voice = controller(this, endlessMic(), recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
await("микрофон начал писать") { voice.state == VoiceState.RECORDING }
|
||||
await("пришёл промежуточный текст") { rec.partials.isNotEmpty() }
|
||||
assertEquals(listOf("при"), rec.partials)
|
||||
|
||||
voice.stop()
|
||||
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
|
||||
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE }
|
||||
|
||||
assertEquals(listOf("привет"), rec.finals)
|
||||
assertTrue(recognizer.finished, "finish() должен быть вызван")
|
||||
assertTrue(recognizer.closed, "recognizer должен быть закрыт")
|
||||
assertEquals(listOf(VoiceState.PREPARING, VoiceState.RECORDING, VoiceState.RECOGNIZING, VoiceState.IDLE), rec.states)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled recording emits nothing`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет")
|
||||
val voice = controller(this, endlessMic(), recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
await("микрофон начал писать") { voice.state == VoiceState.RECORDING }
|
||||
await("пришёл промежуточный текст") { rec.partials.isNotEmpty() }
|
||||
|
||||
voice.cancel()
|
||||
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE }
|
||||
delay(50)
|
||||
|
||||
assertTrue(rec.finals.isEmpty(), "после cancel финал не отдаётся: ${rec.finals}")
|
||||
assertEquals(VoiceState.IDLE, voice.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `microphone ending on its own finishes the turn`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(finalText = "конец")
|
||||
val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
awaitWithDump("финал отдан без stop()", rec, voice) { rec.finals.isNotEmpty() }
|
||||
|
||||
assertEquals(listOf("конец"), rec.finals)
|
||||
assertEquals(VoiceState.IDLE, voice.state)
|
||||
assertTrue(recognizer.closed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `microphone chunks reach the recognizer`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(finalText = "ок")
|
||||
val voice = controller(this, finiteMic(chunk, chunk, chunk), recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
|
||||
|
||||
assertEquals(3, recognizer.fed.size)
|
||||
assertEquals(chunk.toList(), recognizer.fed[0].toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty chunks are skipped`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(finalText = "ок")
|
||||
val voice = controller(this, finiteMic(ByteArray(0), chunk, ByteArray(0)), recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
|
||||
|
||||
assertEquals(1, recognizer.fed.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silence is not turned into text`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(finalText = "", speechDetected = false)
|
||||
val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
awaitWithDump("ошибка про тишину", rec, voice) { rec.errors.isNotEmpty() }
|
||||
|
||||
assertEquals(listOf("Речь не распознана"), rec.errors)
|
||||
assertTrue(rec.finals.isEmpty())
|
||||
assertEquals(VoiceState.IDLE, voice.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty final with speech detected is silently dropped`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(finalText = " ", speechDetected = true)
|
||||
val voice = controller(this, finiteMic(chunk), recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE && recognizer.finished }
|
||||
delay(50)
|
||||
|
||||
assertTrue(rec.finals.isEmpty())
|
||||
assertTrue(rec.errors.isEmpty(), "речь была, но текста нет — это не ошибка: ${rec.errors}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `microphone failure surfaces as error`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer()
|
||||
val mic = MicSource { _, _ ->
|
||||
flow<ByteArray> { throw IllegalStateException("no line") }
|
||||
}
|
||||
val voice = controller(this, mic, recognizer, rec)
|
||||
|
||||
voice.start()
|
||||
awaitWithDump("ошибка микрофона", rec, voice) { rec.errors.isNotEmpty() }
|
||||
|
||||
assertEquals(listOf("Микрофон недоступен: no line"), rec.errors)
|
||||
assertEquals(VoiceState.IDLE, voice.state)
|
||||
assertTrue(recognizer.closed, "распознаватель закрывается и при ошибке микрофона")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognizer creation failure surfaces as error`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val voice = VoiceController(
|
||||
scope = this,
|
||||
recognizers = VoiceRecognizerFactory { throw IllegalStateException("model not found") },
|
||||
mic = finiteMic(chunk),
|
||||
onState = { rec.states += it },
|
||||
onError = { rec.errors += it },
|
||||
)
|
||||
|
||||
voice.start()
|
||||
await("ошибка создания") { rec.errors.isNotEmpty() }
|
||||
|
||||
assertEquals(listOf("Не удалось запустить распознавание: model not found"), rec.errors)
|
||||
assertEquals(VoiceState.IDLE, voice.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle starts then stops`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val recognizer = FakeRecognizer(finalText = "готово")
|
||||
val voice = controller(this, endlessMic(), recognizer, rec)
|
||||
|
||||
voice.toggle()
|
||||
await("пишем") { voice.state == VoiceState.RECORDING }
|
||||
assertTrue(voice.isActive)
|
||||
|
||||
voice.toggle()
|
||||
awaitWithDump("финал", rec, voice) { rec.finals.isNotEmpty() }
|
||||
assertTrue(rec.finals.contains("готово"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop before start does nothing`() = runBlocking {
|
||||
val rec = Recorder()
|
||||
val voice = controller(this, endlessMic(), FakeRecognizer(), rec)
|
||||
|
||||
voice.stop()
|
||||
voice.cancel()
|
||||
delay(30)
|
||||
|
||||
assertEquals(VoiceState.IDLE, voice.state)
|
||||
assertTrue(rec.states.isEmpty(), "никаких переходов не ожидается: ${rec.states}")
|
||||
}
|
||||
|
||||
/** Ожидание условия с реальным временем (см. заметку про ksqlite/Dispatchers). */
|
||||
private suspend fun await(what: String, timeoutMs: Long = 5_000, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition()) return
|
||||
delay(5)
|
||||
}
|
||||
fail("не дождались: $what")
|
||||
}
|
||||
|
||||
/** То же, но с дампом состояния — чтобы падение было диагностируемым. */
|
||||
private suspend fun awaitWithDump(what: String, rec: Recorder, voice: VoiceController, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + 5_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition()) return
|
||||
delay(5)
|
||||
}
|
||||
fail(
|
||||
"не дождались: $what; state=${voice.state} active=${voice.isActive} " +
|
||||
"states=${rec.states} partials=${rec.partials} finals=${rec.finals} errors=${rec.errors}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package pw.binom.agentik.desktop.voice
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
/**
|
||||
* Настоящий тракт: модель Vosk из артефакта `asr-vosk-jvm` + Silero VAD из
|
||||
* `vad-jvm`. Проверяем, что модели находятся, распаковываются и работают, а
|
||||
* тишина не даёт текста.
|
||||
*
|
||||
* Один тест на класс: создание распознавателя грузит нативные модели
|
||||
* (первый запуск распаковывает zip 46 МБ в tmp), а ONNX-сессия VAD не терпит
|
||||
* повторного `close()` — поэтому никаких `use {}` вокруг общих объектов.
|
||||
*/
|
||||
class VoskVoiceRecognizerIT {
|
||||
|
||||
@Test
|
||||
fun `silence produces no text and no speech detection`() {
|
||||
val recognizer = VoskVoiceRecognizer()
|
||||
try {
|
||||
// 1 секунда тишины чанками по 100 мс — ровно как от микрофона.
|
||||
repeat(10) {
|
||||
recognizer.feed(ByteArray(VOICE_SAMPLE_RATE / 10 * 2))
|
||||
}
|
||||
|
||||
// Промежуточного текста на тишине нет…
|
||||
assertEquals(null, recognizer.partial())
|
||||
assertFalse(recognizer.speechDetected, "тишина не должна детектиться как речь")
|
||||
// …и финал пустой.
|
||||
assertEquals("", recognizer.finish())
|
||||
} finally {
|
||||
recognizer.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user