stt: VAD-интерфейс (Sherpa боевая / Silero тестовая), PhraseRecognizer (Whisper), чистый full-text-тест на реальном Silero VAD

This commit is contained in:
2026-08-24 05:43:29 +03:00
parent 7557d7229f
commit c1c169036a
13 changed files with 556 additions and 38 deletions
+196
View File
@@ -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<Float>()` (все сэмплы от начала,
для среза сегментов; 60 с ≈ 3.9 МБ — допустимо), `queue = ArrayDeque<VadSegment>()`.
`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<String> = 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` (батч) не трогаем вообще.