14 KiB
TASK: VAD-интерфейс (стриминг) + две реализации + PhraseRecognizer + «чистый» full-text-тест
Контекст (факты, проверены)
app-phone/.../phone/stt/SttStreamer.kt— стриминг:accept(pcm: ByteArray)(s16le 16 кГц mono) →s16leToFloat→ окна по 512 → sherpavad.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.ktsview-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
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()и импорт sherpaVad/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)
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, правка минимальная)
Было:
val s = SttStreamer(stt = whisper, vadModelPath = vadModel.absolutePath, onPhrase=..., onSilence30s=...)
Стало:
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).
- if inSpeech && (sEnd - lastSpeechEnd) >= 4000 {
if (lastSpeechEnd - segStart) >= 4000 { emit() }; inSpeech = false }
где
popSegment():if (queue.isEmpty()) null else queue.removeFirst().flush(): if (inSpeech && (lastSpeechEnd - segStart) >= 4000) emit().close():runCatching { vad.close() }(VadInstance — AutoCloseable). Конструктор:SileroStreamingVad(), в initprivate val vad = VadInstance(16000)(поднимает ONNX-нативку linux-x64 из jar — в JVM-юните это работает). - if
-
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
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) на тест:
@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(батч) не трогаем вообще.