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
@@ -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<SttDone>(msg)
assertTrue(done.full.isNotBlank())
}
}
// ---------- помощники ----------
@@ -0,0 +1,9 @@
package pw.binom.viewmate.phone.stt
/** Тестовый [PhraseRecognizer]: любая фраза → "привет" (первая строка списка). */
class FakePhraseRecognizer(
private val phrases: List<String> = listOf("привет"),
) : PhraseRecognizer {
override fun recognize(samples: FloatArray): String = phrases[0]
}
@@ -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<Float>() // все сэмплы от начала, для среза сегментов
private val queue = ArrayDeque<VadSegment>()
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)
}
}
@@ -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<VadSegment>()
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()
}
}
@@ -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)
}
}