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
@@ -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)