Десктоп-клиент: 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,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-чанка")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user