Десктоп-клиент: 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:
2026-09-28 10:08:10 +03:00
parent 27f349550c
commit 2c71542b13
53 changed files with 9147 additions and 0 deletions
@@ -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()
}
}
}