# TASK: VAD-интерфейс (стриминг) + две реализации + PhraseRecognizer + «чистый» full-text-тест ## Контекст (факты, проверены) - `app-phone/.../phone/stt/SttStreamer.kt` — стриминг: `accept(pcm: ByteArray)` (s16le 16 кГц mono) → `s16leToFloat` → окна по 512 → sherpa `vad.acceptWaveform(front()/pop()/flush())` → `stt.recognize(samples)` (WhisperStt, нативка) → `full` → `finish()` возвращает весь текст. - `WhisperStt` — класс с методом `recognize(samples: FloatArray): String` (sherpa OfflineRecognizer). - Батч-`VadSegmenter` (оффлайн, SttDebug) — Android-only, ЭТОТ МОДУЛЬ НЕ ТРОГАЕМ (п. 7). - Новая библиотека (mavenLocal, уже опубликована!): `pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT`: `pw.binom.ai.voice.vad.VadInstance(16000)` : `java.lang.AutoCloseable`, `windowSizeSamples` (512 при 16 кГц), `processingWindow(FloatArray|FloatBuffer): Float` (вероятность речи на окно 512 сэмплов, истинный Silero на ONNX 1.16.0; реальный onnx-файл `silero_vad.onnx` ВНУТРИ jar; нативка linux-x64 в зависимости onnxruntime — поднимается на JVM). Зависимости jar: kotlin-stdlib + onnxruntime + JTransforms (pw.binom УБРАН — проверено по .module). - `settings.gradle.kts` view-mate уже содержит `mavenLocal()` в dependencyResolutionManagement ✓. - Фикстур: `voice-samples/masha-anton.wav` — 16 кГц, mono, s16le, 60.2 с (в корне репо). ## Правки ### 1. Новый интерфейс (MAIN): `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/StreamingVad.kt` ```kotlin package pw.binom.viewmate.phone.stt /** Сегмент речи: [startSample, startSample+samples.size) в сэмплах 16 кГц. */ class VadSegment(val startSample: Int, val samples: FloatArray) /** * Стриминговый VAD: подача окнами по windowSizeSamples → готовые сегменты. * Реализации: [SherpaStreamingVad] (боевая, Android), SileroStreamingVad (тесты, JVM). */ interface StreamingVad { val windowSizeSamples: Int /** Подать окно; готовые сегменты (если появились) забираются popSegment(). */ fun acceptWindow(window: FloatArray) /** Последний готовый сегмент (копию caller должен делать сам, если нужно удерживать). */ fun popSegment(): VadSegment? /** Закрыть текущий незавершённый сегмент (если есть). */ fun flush() fun close() } ``` ### 2. Новая боевая реализация (MAIN): `.../phone/stt/SherpaStreamingVad.kt` Обёртка над `com.k2fsa.sherpa.onnx.Vad` (именно текущий код из `SttStreamer.vadConfig()`): SileroVadModelConfig(model=modelPath, threshold=0.5f, minSilenceDuration=0.25f, minSpeechDuration=0.25f, windowSize=512, maxSpeechDuration=20f), sampleRate=16000, numThreads=1, provider="cpu". - `acceptWindow(w)` = `vad.acceptWaveform(w)`. - `popSegment()`: `if (vad.empty()) null else { val s = vad.front(); val out = VadSegment(s.start, s.samples); vad.pop(); out }` - `flush()` = `vad.flush()` (после — сегменты через popSegment). - `close()` = `runCatching { vad.release() }`. - Класс закрывает AutoCloseable, НЕ наследуя ничего (close — обычная сигнатура). ### 3. Конструктор `SttStreamer` (MAIN, правим SttStreamer.kt) - Было: `SttStreamer(stt: WhisperStt, vadModelPath: String, onPhrase, onSilence30s)` + поле `private val vad: Vad = Vad(null, vadConfig())`. - Стало: `SttStreamer(stt: PhraseRecognizer, vad: StreamingVad, onPhrase: (String,String)->Unit, onSilence30s: () -> Unit)`. Убрать `vadConfig()` и импорт sherpa `Vad/VadModelConfig/SileroVadModelConfig` из SttStreamer (теперь они в SherpaStreamingVad). - `feedWindow(window)` → `vad.acceptWindow(window); var seg = vad.popSegment(); while (seg != null) { recognizeSegment(seg.samples.copyOf()); seg = vad.popSegment() }` - `finish()` → `vad.flush(); var seg = vad.popSegment(); while (seg != null) { recognizeSegment(seg.samples.copyOf()); seg = vad.popSegment() }; (ожидание lastFuture как сейчас)` - `resetLocked()` → `vad.flush(); var seg = vad.popSegment(); while (seg != null) { seg = vad.popSegment() }; (остальное как сейчас)` - `close()` → `vad.close(); executor.shutdownNow(); watchdog.interrupt()` (как сейчас, только vad.close() вместо vad.release()). - Всё остальное (lock, pending, session, deadline, s16leToFloat) — БЕЗ ИЗМЕНЕНИЙ. ### 4. Новая реализация PhraseRecognizer (MAIN, новый файл `.../phone/stt/PhraseRecognizer.kt`) ```kotlin package pw.binom.viewmate.phone.stt /** Распознавание одной фразы (16 кГц mono PCM) в текст. */ interface PhraseRecognizer { fun recognize(samples: FloatArray): String } ``` `WhisperStt` (MAIN) — добавить реализацию: `class WhisperStt(...) : PhraseRecognizer` (метод уже совпадает по сигнатуре; только добавить интерфейс + import). ### 5. PhoneApp.kt — `ensureStt()` (MAIN, правка минимальная) Было: ```kotlin val s = SttStreamer(stt = whisper, vadModelPath = vadModel.absolutePath, onPhrase=..., onSilence30s=...) ``` Стало: ```kotlin val s = SttStreamer(stt = whisper, vad = SherpaStreamingVad(vadModel.absolutePath), onPhrase=..., onSilence30s=...) ``` (импорт `pw.binom.viewmate.phone.stt.SherpaStreamingVad`; остальное в PhoneApp не трогать). ### 6. Тестовые исходники (app-phone/src/test/.../phone/stt/) - `SileroStreamingVad.kt` — реализация `StreamingVad` над `pw.binom.ai.voice.vad.VadInstance(16000)`. Точный стриминговый постпроцесс (порядок окон, 512 сэмплов каждое; параметры те же, что у sherpa, для поведенческого паритета: threshold 0.5, minSilence 0.25 с = 4000 сэмплов, minSpeech 0.25 с = 4000 сэмплов, maxSpeech 20 с = 320000 сэмплов): Состояние: `inSpeech: Boolean = false`, `segStart: Int = 0`, `lastSpeechEnd: Int = 0`, `total: Int = 0` (обработано сэмплов), `buf = ArrayList()` (все сэмплы от начала, для среза сегментов; 60 с ≈ 3.9 МБ — допустимо), `queue = ArrayDeque()`. `acceptWindow(w: FloatArray)`: `require(w.size == 512)`; `val p = vad.processingWindow(w)`; `val s = total; val sEnd = s + 512; buf.addAll(w); total = sEnd`. - if `p >= 0.5f`: - if !inSpeech { inSpeech = true; segStart = s } - lastSpeechEnd = sEnd - if (lastSpeechEnd - segStart) >= 320000 { emit(); inSpeech = false } - else: - if inSpeech && (sEnd - lastSpeechEnd) >= 4000 { if (lastSpeechEnd - segStart) >= 4000 { emit() }; inSpeech = false } где `emit()`: `val seg = VadSegment(segStart, FloatArray(lastSpeechEnd - segStart){ buf[segStart + it] }); queue.addLast(seg)`. `popSegment()`: `if (queue.isEmpty()) null else queue.removeFirst()`. `flush()`: if (inSpeech && (lastSpeechEnd - segStart) >= 4000) emit(). `close()`: `runCatching { vad.close() }` (VadInstance — AutoCloseable). Конструктор: `SileroStreamingVad()`, в init `private val vad = VadInstance(16000)` (поднимает ONNX-нативку linux-x64 из jar — в JVM-юните это работает). - `FakePhraseRecognizer.kt` — `class FakePhraseRecognizer(private val phrases: List = listOf("привет"))` : `PhraseRecognizer { override fun recognize(s: FloatArray): String = phrases[0] }` (тесту достаточно одной фразы "привет"; при желании счётчик по index, но не требуется). - `WavPcm.kt` (тест) — минимальный загрузчик: `fun loadPcm(path: String): ByteArray` — читает RIFF WAV s16le mono 16000 (header + raw PCM → ByteArray), если sampleRate/channels не 16000/mono — fail(). (Можно переиспользовать `WavReader` из main — он принимает InputStream и отдаёт FloatArray; нам нужен именно ByteArray s16le — проще написать свой 20-строчный по факту, т.к. WavReader отдаёт FloatArray и конверсию float→s16le обратно — глупость. Пишем свой.) ### 7. `VadSegmenter.kt` (MAIN) — НЕ ТРОГАТЬ. SttDebug — не трогаем (Android-only). ### 8. Зависимость в `app-phone/build.gradle.kts` ```kotlin testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT") ``` (в блоке testImplementation рядом с kotlin("test")). mavenLocal() уже в settings ✓. ### 9. Восстановление full-text-теста в `app-phone/src/test/.../phone/GlassesServerTest.kt` Заменить коммент (стр. ~197-200) на тест: ```kotlin @Test fun stopSttFullTextSendsSttDone() = runServer { port, hub -> hub.sttFactory = { SttStreamer( stt = FakePhraseRecognizer(), vad = SileroStreamingVad(), onPhrase = { _, _ -> }, onSilence30s = { }, ) } val pcm = WavPcm.loadPcm("../voice-samples/masha-anton.wav") // кидать по 3200 байт (100 мс @ 16к s16 mono) var off = 0 while (off < pcm.size) { val end = minOf(off + 3200, pcm.size) hub.handle("c1", protocolJson.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end)))) off = end } hub.handle("c1", protocolJson.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false))) // ждём SttDone с непустым текстом (все «привет» от фейка по числу сегментов VAD) val got = awaitTrue { hub.sttDoneText?.isNotBlank() ?: false } // по факту: смотреть, как assert that SttDone получен и текст = "привет" повторён N раз (N — число сегментов, ≥1) } ``` ВАЖНО: посмотреть по факту, как тесты этого файла ловят broadcast (есть ли у hub очередь SttDone или шлём на ws-клиент `wsClient()` — тесты здесь через Ktor ws-клиент). Использовать существующий паттерн `wsClient()` + `awaitTrue` — как в stopSttCancelSendsSttCancelReasonCancel (стр. 173). Текст: `SttCancel`/`SttDone` ловятся на `hub.broadcast` → ws-клиент. Имя SttDone смотреть в `HostToGlasses.kt` (lib-core) — это data-класс `SttDone(val text: String)` (по факту сверить). ### 10. Второй тест (ЧИСТЫЙ, без фейков — реальный VAD): `SileroVadRealAudioTest.kt` (test) - `fun segmentsOnRealAudio()`: `SileroStreamingVad()`, кидаем весь PCM (790 656 сэмплов = 60 с) окнами по 512 → popSegment-цикл → assert: - segments ≥ 1, - каждый сегмент: start >= 0, start+len <= total, - суммарная речь в пределах [5000, 500000] сэмплов (0.3с — 31с), - после flush() popSegment() == null. - Это и есть «чистый тест распознавания» (VAD реальный, ONNX реальный, без pw.binom в classpath). ### 11. Прогон (ВСЁ, без исключений) - `./gradlew :lib-core:jvmTest` — зелёные (81). - `./gradlew :app-phone:testDebugUnitTest` — зелёные (156 + 2 новых = ~158, включая восстановленный full-text и SileroVadRealAudio). - `./gradlew :app-glasses:testDebugUnitTest` — 91, не сломать. - `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — BUILD SUCCESSFUL. - Если `../voice-samples/masha-anton.wav` не резолвится из workdir теста (gradle test working dir) — проверить `./gradlew :app-phone:testDebugUnitTest --tests "*SileroVadRealAudio*" -i` → рабочий каталог; если не тот — читать через `Path.of("../voice-samples/masha-anton.wav").toAbsolutePath()`. ### 12. Коммит - `git add -A && git commit -m "stt: VAD-интерфейс (Sherpa боевая / Silero тестовая), PhraseRecognizer (Whisper), чистый full-text-тест на реальном Silero VAD"` - НЕ push. ## Оковы - Пути ТОЛЬКО относительные от корня /root/WORK/view-mate. Никаких /tmp. - ЗАПРЕЩЕНО трогать: `lib-core/.../HostToGlasses.kt`, `app-phone/.../GlassesServer.kt`, `app-glasses/.../HostConnection.kt`, `app-glasses/.../MainActivity.kt` (GlassesServer не правится — стtFactory уже там; все правки вокруг него). - SttStreamer.kt разрешён (сейчас правится). PhoneApp.kt — только ensureStt() (2 строки). - В MAIN source set — НУЛЬ упоминаний `pw.binom.voice.vad` (Silero — только в test). - `VadSegmenter.kt` (батч) не трогаем вообще.