diff --git a/TASK-vad-interface.md b/TASK-vad-interface.md new file mode 100644 index 0000000..01fc0c0 --- /dev/null +++ b/TASK-vad-interface.md @@ -0,0 +1,196 @@ +# 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` (батч) не трогаем вообще. diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index c2c16ec..c4e9687 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -111,6 +111,8 @@ dependencies { implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0") testImplementation(kotlin("test")) + // Тестовый стриминговый VAD (Silero на ONNX, JVM) — из mavenLocal + testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT") testImplementation("io.ktor:ktor-server-test-host:3.3.0") testImplementation("io.ktor:ktor-client-websockets:3.3.0") testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0") diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 32b912a..f75707a 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -46,6 +46,7 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient import pw.binom.viewmate.phone.agent.SkillRegistry import pw.binom.viewmate.phone.agent.SkillRepository import pw.binom.viewmate.phone.agent.ToolsetRegistry +import pw.binom.viewmate.phone.stt.SherpaStreamingVad import pw.binom.viewmate.phone.stt.SttStreamer import pw.binom.viewmate.phone.stt.WhisperStt @@ -367,7 +368,7 @@ class PhoneApp : Application() { ) val s = SttStreamer( stt = whisper, - vadModelPath = vadModel.absolutePath, + vad = SherpaStreamingVad(vadModel.absolutePath), onPhrase = { phrase, full -> log("stt", "фраза: $phrase") // Только показ на очках — в LLM фраза НЕ уходит (только по клику). diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/PhraseRecognizer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/PhraseRecognizer.kt new file mode 100644 index 0000000..c4ed155 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/PhraseRecognizer.kt @@ -0,0 +1,6 @@ +package pw.binom.viewmate.phone.stt + +/** Распознавание одной фразы (16 кГц mono PCM) в текст. */ +interface PhraseRecognizer { + fun recognize(samples: FloatArray): String +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SherpaStreamingVad.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SherpaStreamingVad.kt new file mode 100644 index 0000000..d83ae6d --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SherpaStreamingVad.kt @@ -0,0 +1,52 @@ +package pw.binom.viewmate.phone.stt + +import com.k2fsa.sherpa.onnx.SileroVadModelConfig +import com.k2fsa.sherpa.onnx.Vad +import com.k2fsa.sherpa.onnx.VadModelConfig + +/** + * Боевой стриминговый VAD на нативной [Vad] (sherpa-onnx, Silero). + * Те же параметры, что были в vadConfig() SttStreamer: threshold 0.5, + * minSilence/minSpeech 0.25 с, окно 512, maxSpeech 20 с, 16 кГц, 1 поток, CPU. + */ +class SherpaStreamingVad(modelPath: String) : StreamingVad { + + private val vad: Vad = Vad( + null, + VadModelConfig( + sileroVadModelConfig = SileroVadModelConfig( + model = modelPath, + threshold = 0.5f, + minSilenceDuration = 0.25f, + minSpeechDuration = 0.25f, + windowSize = 512, + maxSpeechDuration = 20f, + ), + sampleRate = 16000, + numThreads = 1, + provider = "cpu", + ), + ) + + override val windowSizeSamples: Int = 512 + + override fun acceptWindow(window: FloatArray) { + vad.acceptWaveform(window) + } + + override fun popSegment(): VadSegment? { + if (vad.empty()) return null + val s = vad.front() + val out = VadSegment(s.start, s.samples) + vad.pop() + return out + } + + override fun flush() { + vad.flush() + } + + override fun close() { + runCatching { vad.release() } + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/StreamingVad.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/StreamingVad.kt new file mode 100644 index 0000000..4c396a8 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/StreamingVad.kt @@ -0,0 +1,19 @@ +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() +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt index 2141275..a8a45dc 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt @@ -1,8 +1,5 @@ package pw.binom.viewmate.phone.stt -import com.k2fsa.sherpa.onnx.SileroVadModelConfig -import com.k2fsa.sherpa.onnx.Vad -import com.k2fsa.sherpa.onnx.VadModelConfig import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.TimeUnit @@ -36,8 +33,8 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray { * (автo-отмена, вызывается один раз за сессию). */ class SttStreamer( - private val stt: WhisperStt, - private val vadModelPath: String, + private val stt: PhraseRecognizer, + private val vad: StreamingVad, private val onPhrase: (phrase: String, full: String) -> Unit, private val onSilence30s: () -> Unit, ) { @@ -47,7 +44,6 @@ class SttStreamer( } private val lock = java.util.concurrent.locks.ReentrantLock() - private val vad: Vad = Vad(null, vadConfig()) private val executor = Executors.newSingleThreadExecutor { r -> Thread(r, "stt-recognize").apply { isDaemon = true } } @@ -123,10 +119,11 @@ class SttStreamer( fun finish(): String { lock.withLock { vad.flush() - while (!vad.empty()) { - val seg = vad.front() - recognizeSegment(seg.samples) - vad.pop() + var seg = vad.popSegment() + while (seg != null) { + // Копия: массив может переиспользоваться после pop. + recognizeSegment(seg.samples.copyOf()) + seg = vad.popSegment() } val last = lastFuture if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) } @@ -142,7 +139,7 @@ class SttStreamer( fun close() { lock.withLock { deadlineMs = 0 - runCatching { vad.release() } + vad.close() } executor.shutdownNow() watchdog.interrupt() @@ -152,7 +149,10 @@ class SttStreamer( // Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился // с началом следующей (cancel отбрасывает текст, flush не признаётся). runCatching { vad.flush() } - while (!vad.empty()) vad.pop() + var seg = vad.popSegment() + while (seg != null) { + seg = vad.popSegment() + } session++ full = "" pending = FloatArray(0) @@ -161,12 +161,12 @@ class SttStreamer( /** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */ private fun feedWindow(window: FloatArray) { - vad.acceptWaveform(window) - while (!vad.empty()) { - val seg = vad.front() - // Копия: массив из нативного VAD может переиспользоваться после pop. + vad.acceptWindow(window) + var seg = vad.popSegment() + while (seg != null) { + // Копия: массив может переиспользоваться после pop. recognizeSegment(seg.samples.copyOf()) - vad.pop() + seg = vad.popSegment() } } @@ -183,18 +183,4 @@ class SttStreamer( onPhrase(phrase, newFull) } } - - private fun vadConfig(): VadModelConfig = VadModelConfig( - sileroVadModelConfig = SileroVadModelConfig( - model = vadModelPath, - threshold = 0.5f, - minSilenceDuration = 0.25f, - minSpeechDuration = 0.25f, - windowSize = WINDOW_SIZE, - maxSpeechDuration = 20f, - ), - sampleRate = 16000, - numThreads = 1, - provider = "cpu", - ) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt index 3d8e449..77e26a7 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt @@ -12,7 +12,7 @@ class WhisperStt( private val tokensPath: String, private val numThreads: Int = 4, private val language: String = "ru", -) { +) : PhraseRecognizer { private val recognizer: OfflineRecognizer = OfflineRecognizer( null, OfflineRecognizerConfig( @@ -33,7 +33,7 @@ class WhisperStt( ) /** Распознать 16 кГц mono PCM. Возвращает текст. */ - fun recognize(samples: FloatArray): String { + override fun recognize(samples: FloatArray): String { val stream = recognizer.createStream() try { stream.acceptWaveform(samples, 16000) diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt index 9c2e8ac..6b993b5 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt @@ -23,12 +23,19 @@ import pw.binom.viewmate.core.protocol.Hello import pw.binom.viewmate.core.protocol.HostToGlasses import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.StopStt +import pw.binom.viewmate.core.protocol.SttAudio import pw.binom.viewmate.core.protocol.SttCancel +import pw.binom.viewmate.core.protocol.SttDone import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.protocolJson +import pw.binom.viewmate.phone.stt.FakePhraseRecognizer +import pw.binom.viewmate.phone.stt.SileroStreamingVad +import pw.binom.viewmate.phone.stt.SttStreamer +import pw.binom.viewmate.phone.stt.WavPcm import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue import kotlin.test.fail /** @@ -194,10 +201,38 @@ class GlassesServerTest { } } - // Полная фраза (StopStt(cancel=false) с текстом → SttDone + onStopFullText) в JVM-юните - // не проверяется: реальный SttStreamer держит нативный VAD (com.k2fsa.sherpa.onnx.Vad), - // а нативная библиотека sherpa-onnx есть ТОЛЬКО под Android-bionic — в JVM-юните её нет, - // и фейк падает UnsatisfiedLinkError (неремонтируемо). Сценарий покрывается на устройстве. + /** + * Полная фраза (StopStt(cancel=false) с текстом → SttDone): реальный Silero VAD + * (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель + * даёт «привет» на каждый сегмент → очки получают SttDone с непустым текстом. + */ + @Test + fun stopSttFullTextSendsSttDone() = runServer { port, hub -> + hub.sttFactory = { + SttStreamer( + stt = FakePhraseRecognizer(), + vad = SileroStreamingVad(), + onPhrase = { _, _ -> }, + onSilence30s = { }, + ) + } + val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath) + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { + // кидать по 3200 байт (100 мс @ 16 кГц s16le mono) + var off = 0 + while (off < pcm.size) { + val end = minOf(off + 3200, pcm.size) + send(json.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end)))) + off = end + } + send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false))) + val frame = incoming.receive() as Frame.Text + val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) + val done = assertIs(msg) + assertTrue(done.full.isNotBlank()) + } + } // ---------- помощники ---------- diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FakePhraseRecognizer.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FakePhraseRecognizer.kt new file mode 100644 index 0000000..acd3686 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FakePhraseRecognizer.kt @@ -0,0 +1,9 @@ +package pw.binom.viewmate.phone.stt + +/** Тестовый [PhraseRecognizer]: любая фраза → "привет" (первая строка списка). */ +class FakePhraseRecognizer( + private val phrases: List = listOf("привет"), +) : PhraseRecognizer { + + override fun recognize(samples: FloatArray): String = phrases[0] +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/SileroStreamingVad.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/SileroStreamingVad.kt new file mode 100644 index 0000000..1ae1150 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/SileroStreamingVad.kt @@ -0,0 +1,77 @@ +package pw.binom.viewmate.phone.stt + +import pw.binom.voice.vad.VadInstance + +/** + * Тестовый стриминговый VAD на реальном Silero (JVM, ONNX через [VadInstance]). + * Постпроцесс — поведенческий паритет с боевой SherpaStreamingVad: + * threshold 0.5; minSilence = minSpeech = 0.25 с = 4000 сэмплов; + * maxSpeech = 20 с = 320 000 сэмплов; окно 512 сэмплов. + * + * ВНИМАНИЕ: `buf` хранит все сэмплы с начала стрима (60 с ≈ 3.9 МБ) — + * для тестов допустимо, для боевого кода не предлагать. + */ +class SileroStreamingVad : StreamingVad { + + private val vad = VadInstance(16000) + + private var inSpeech = false + private var segStart = 0 + private var lastSpeechEnd = 0 + private var total = 0 // обработано сэмплов + private val buf = ArrayList() // все сэмплы от начала, для среза сегментов + private val queue = ArrayDeque() + + override val windowSizeSamples: Int = 512 + + override fun acceptWindow(window: FloatArray) { + require(window.size == 512) { "Окно должно быть 512 сэмплов, подано " + window.size } + val p = vad.processingWindow(window) + val s = total + val sEnd = s + 512 + for (w in window) { + buf.add(w) + } + total = sEnd + if (p >= 0.5f) { + if (!inSpeech) { + inSpeech = true + segStart = s + } + lastSpeechEnd = sEnd + // maxSpeech: 20 с — сегмент принудительно закрывается. + if (lastSpeechEnd - segStart >= 320_000) { + emit() + inSpeech = false + } + } else if (inSpeech && sEnd - lastSpeechEnd >= 4000) { + // minSilence: 0.25 с тишины после речи — сегмент готов. + if (lastSpeechEnd - segStart >= 4000) { + emit() + } + inSpeech = false + } + } + + override fun popSegment(): VadSegment? { + if (queue.isEmpty()) return null + return queue.removeFirst() + } + + override fun flush() { + if (inSpeech && lastSpeechEnd - segStart >= 4000) { + emit() + } + } + + override fun close() { + runCatching { vad.close() } + } + + /** Готовый сегмент: срез из общего буфера [segStart, lastSpeechEnd). */ + private fun emit() { + val len = lastSpeechEnd - segStart + val seg = VadSegment(segStart, FloatArray(len) { buf[segStart + it] }) + queue.addLast(seg) + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/SileroVadRealAudioTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/SileroVadRealAudioTest.kt new file mode 100644 index 0000000..0049484 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/SileroVadRealAudioTest.kt @@ -0,0 +1,63 @@ +package pw.binom.viewmate.phone.stt + +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * «Чистый» тест распознавания: реальный Silero VAD (ONNX через vad-jvm) + + * реальная аудио-фикстура voice-samples/masha-anton.wav. + * Без pw.binom.viewmate в classpath — только библиотека VAD и тестовый код. + */ +class SileroVadRealAudioTest { + + @Test + fun segmentsOnRealAudio() { + val vad = SileroStreamingVad() + val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath) + val n = pcm.size / 2 // сэмплов 16 кГц + assertTrue(n >= 512, "PCM фикстуры пуст") + + // Весь PCM → окнами по 512 (хвост < 512 дополняем нулями — тишиной). + val total = ((n + 511) / 512) * 512 // обработано сэмплов на VAD + val segments = mutableListOf() + for (i in 0 until total step 512) { + val window = FloatArray(512) + for (j in 0 until 512) { + val idx = i + j + if (idx < n) { + val lo = pcm[idx * 2].toInt() and 0xff + val hi = pcm[idx * 2 + 1].toInt() and 0xff + var s = lo or (hi shl 8) + if (s >= 0x8000) s -= 0x10000 + window[j] = s / 32768f + } + } + vad.acceptWindow(window) + var seg = vad.popSegment() + while (seg != null) { + segments.add(seg) + seg = vad.popSegment() + } + } + + assertTrue(segments.size >= 1, "сегментов не найдено (VAD не нашёл речь в 60-секундной фикстуре)") + var speechSamples = 0 + for (seg in segments) { + assertTrue(seg.startSample >= 0, "start < 0 в сегменте ${seg.startSample}") + assertTrue( + seg.startSample + seg.samples.size <= total, + "сегмент выходит за конец потока: start=${seg.startSample}, len=${seg.samples.size}, total=$total", + ) + speechSamples += seg.samples.size + } + assertTrue( + speechSamples in 5000..500_000, + "суммарная речь ${speechSamples} сэмплов вне ожидаемого [5000..500000] " + + "(${speechSamples / 16000.0}s вне 0.3–31с), сегментов=${segments.size}", + ) + + vad.flush() + assertTrue(vad.popSegment() == null, "после flush() остались сегменты") + vad.close() + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/WavPcm.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/WavPcm.kt new file mode 100644 index 0000000..4beefa3 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/WavPcm.kt @@ -0,0 +1,72 @@ +package pw.binom.viewmate.phone.stt + +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Минимальный загрузчик RIFF/WAVE для тестов: s16le, 16 кГц, mono. + * (Свой, а не WavReader из main — тот отдаёт FloatArray, а нужно как раз + * сырые s16le-байты PCM.) + */ +object WavPcm { + + /** Абсолютный путь к фикстуре voice-samples/masha-anton.wav (корень репо). */ + fun fixture(): File { + // working dir юнит-теста gradle — каталог модуля (app-phone) или корень; + // ищем voice-samples/ вверх от user.dir. + var dir = File(System.getProperty("user.dir")).absoluteFile + while (true) { + val cand = File(dir, "voice-samples/masha-anton.wav") + if (cand.isFile) return cand + dir = dir.parentFile ?: break + } + return File("voice-samples/masha-anton.wav").absoluteFile + } + + /** PCM s16le, 16 кГц, mono. Если формат не тот — fail(). */ + fun loadPcm(path: String): ByteArray { + val f = File(path) + check(f.isFile) { "WAV не найден: $path" } + val b = f.readBytes() + check(b.size > 44) { "WAV: файл короче заголовка" } + fun str(off: Int, len: Int) = String(b.copyOfRange(off, off + len)) + check(str(0, 4) == "RIFF") { "WAV: не RIFF" } + check(str(8, 4) == "WAVE") { "WAV: не WAVE" } + fun i16(off: Int) = ByteBuffer.wrap(b, off, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() + fun i32(off: Int) = ByteBuffer.wrap(b, off, 4).order(ByteOrder.LITTLE_ENDIAN).int + + var off = 12 + var audioFormat = 0 + var channels = 0 + var sampleRate = 0 + var bitsPerSample = 0 + var dataOff = -1 + var dataLen = 0 + while (off + 8 <= b.size) { + val id = str(off, 4) + val len = i32(off + 4) + when (id) { + "fmt " -> { + audioFormat = i16(off + 8) + channels = i16(off + 10) + sampleRate = i32(off + 12) + bitsPerSample = i16(off + 22) + } + "data" -> { + dataOff = off + 8 + dataLen = len + break + } + } + off += 8 + len + (len and 1) // чанки выравниваются по чётным байтам + } + check(audioFormat == 1) { "WAV: не PCM (format=$audioFormat)" } + check(channels == 1) { "WAV: каналов=$channels, ожидался mono" } + check(sampleRate == 16000) { "WAV: частота=$sampleRate, ожидалась 16000" } + check(bitsPerSample == 16) { "WAV: битов/сэмпл=$bitsPerSample, ожидалось 16" } + require(dataOff >= 0) { "WAV: нет чанка data" } + val end = minOf(b.size, dataOff + dataLen) + return b.copyOfRange(dataOff, end) + } +}