stt: VAD-интерфейс (Sherpa боевая / Silero тестовая), PhraseRecognizer (Whisper), чистый full-text-тест на реальном Silero VAD
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user