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
+2
View File
@@ -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")
@@ -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 фраза НЕ уходит (только по клику).
@@ -0,0 +1,6 @@
package pw.binom.viewmate.phone.stt
/** Распознавание одной фразы (16 кГц mono PCM) в текст. */
interface PhraseRecognizer {
fun recognize(samples: FloatArray): String
}
@@ -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() }
}
}
@@ -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()
}
@@ -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",
)
}
@@ -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)
@@ -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)
}
}