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
+196
View File
@@ -0,0 +1,196 @@
# TASK: VAD-интерфейс (стриминг) + две реализации + PhraseRecognizer + «чистый» full-text-тест
## Контекст (факты, проверены)
- `app-phone/.../phone/stt/SttStreamer.kt` — стриминг: `accept(pcm: ByteArray)` (s16le 16 кГц mono)
→ `s16leToFloat` → окна по 512 → sherpa `vad.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.kts` view-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`
```kotlin
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()` и импорт sherpa `Vad/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`)
```kotlin
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, правка минимальная)
Было:
```kotlin
val s = SttStreamer(stt = whisper, vadModelPath = vadModel.absolutePath, onPhrase=..., onSilence30s=...)
```
Стало:
```kotlin
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)`.
`popSegment()`: `if (queue.isEmpty()) null else queue.removeFirst()`.
`flush()`: if (inSpeech && (lastSpeechEnd - segStart) >= 4000) emit().
`close()`: `runCatching { vad.close() }` (VadInstance — AutoCloseable).
Конструктор: `SileroStreamingVad()`, в init `private val vad = VadInstance(16000)`
(поднимает ONNX-нативку linux-x64 из jar — в JVM-юните это работает).
- `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`
```kotlin
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) на тест:
```kotlin
@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` (батч) не трогаем вообще.
+2
View File
@@ -111,6 +111,8 @@ dependencies {
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0") implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
testImplementation(kotlin("test")) 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-server-test-host:3.3.0")
testImplementation("io.ktor:ktor-client-websockets:3.3.0") testImplementation("io.ktor:ktor-client-websockets:3.3.0")
testImplementation("io.ktor:ktor-client-content-negotiation: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.SkillRegistry
import pw.binom.viewmate.phone.agent.SkillRepository import pw.binom.viewmate.phone.agent.SkillRepository
import pw.binom.viewmate.phone.agent.ToolsetRegistry 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.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt import pw.binom.viewmate.phone.stt.WhisperStt
@@ -367,7 +368,7 @@ class PhoneApp : Application() {
) )
val s = SttStreamer( val s = SttStreamer(
stt = whisper, stt = whisper,
vadModelPath = vadModel.absolutePath, vad = SherpaStreamingVad(vadModel.absolutePath),
onPhrase = { phrase, full -> onPhrase = { phrase, full ->
log("stt", "фраза: $phrase") log("stt", "фраза: $phrase")
// Только показ на очках — в LLM фраза НЕ уходит (только по клику). // Только показ на очках — в 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 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.Executors
import java.util.concurrent.Future import java.util.concurrent.Future
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -36,8 +33,8 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
* (автo-отмена, вызывается один раз за сессию). * (автo-отмена, вызывается один раз за сессию).
*/ */
class SttStreamer( class SttStreamer(
private val stt: WhisperStt, private val stt: PhraseRecognizer,
private val vadModelPath: String, private val vad: StreamingVad,
private val onPhrase: (phrase: String, full: String) -> Unit, private val onPhrase: (phrase: String, full: String) -> Unit,
private val onSilence30s: () -> Unit, private val onSilence30s: () -> Unit,
) { ) {
@@ -47,7 +44,6 @@ class SttStreamer(
} }
private val lock = java.util.concurrent.locks.ReentrantLock() private val lock = java.util.concurrent.locks.ReentrantLock()
private val vad: Vad = Vad(null, vadConfig())
private val executor = Executors.newSingleThreadExecutor { r -> private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "stt-recognize").apply { isDaemon = true } Thread(r, "stt-recognize").apply { isDaemon = true }
} }
@@ -123,10 +119,11 @@ class SttStreamer(
fun finish(): String { fun finish(): String {
lock.withLock { lock.withLock {
vad.flush() vad.flush()
while (!vad.empty()) { var seg = vad.popSegment()
val seg = vad.front() while (seg != null) {
recognizeSegment(seg.samples) // Копия: массив может переиспользоваться после pop.
vad.pop() recognizeSegment(seg.samples.copyOf())
seg = vad.popSegment()
} }
val last = lastFuture val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) } if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
@@ -142,7 +139,7 @@ class SttStreamer(
fun close() { fun close() {
lock.withLock { lock.withLock {
deadlineMs = 0 deadlineMs = 0
runCatching { vad.release() } vad.close()
} }
executor.shutdownNow() executor.shutdownNow()
watchdog.interrupt() watchdog.interrupt()
@@ -152,7 +149,10 @@ class SttStreamer(
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился // Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
// с началом следующей (cancel отбрасывает текст, flush не признаётся). // с началом следующей (cancel отбрасывает текст, flush не признаётся).
runCatching { vad.flush() } runCatching { vad.flush() }
while (!vad.empty()) vad.pop() var seg = vad.popSegment()
while (seg != null) {
seg = vad.popSegment()
}
session++ session++
full = "" full = ""
pending = FloatArray(0) pending = FloatArray(0)
@@ -161,12 +161,12 @@ class SttStreamer(
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */ /** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
private fun feedWindow(window: FloatArray) { private fun feedWindow(window: FloatArray) {
vad.acceptWaveform(window) vad.acceptWindow(window)
while (!vad.empty()) { var seg = vad.popSegment()
val seg = vad.front() while (seg != null) {
// Копия: массив из нативного VAD может переиспользоваться после pop. // Копия: массив может переиспользоваться после pop.
recognizeSegment(seg.samples.copyOf()) recognizeSegment(seg.samples.copyOf())
vad.pop() seg = vad.popSegment()
} }
} }
@@ -183,18 +183,4 @@ class SttStreamer(
onPhrase(phrase, newFull) 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 tokensPath: String,
private val numThreads: Int = 4, private val numThreads: Int = 4,
private val language: String = "ru", private val language: String = "ru",
) { ) : PhraseRecognizer {
private val recognizer: OfflineRecognizer = OfflineRecognizer( private val recognizer: OfflineRecognizer = OfflineRecognizer(
null, null,
OfflineRecognizerConfig( OfflineRecognizerConfig(
@@ -33,7 +33,7 @@ class WhisperStt(
) )
/** Распознать 16 кГц mono PCM. Возвращает текст. */ /** Распознать 16 кГц mono PCM. Возвращает текст. */
fun recognize(samples: FloatArray): String { override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream() val stream = recognizer.createStream()
try { try {
stream.acceptWaveform(samples, 16000) 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.HostToGlasses
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.StopStt 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.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.fail import kotlin.test.fail
/** /**
@@ -194,10 +201,38 @@ class GlassesServerTest {
} }
} }
// Полная фраза (StopStt(cancel=false) с текстом → SttDone + onStopFullText) в JVM-юните /**
// не проверяется: реальный SttStreamer держит нативный VAD (com.k2fsa.sherpa.onnx.Vad), * Полная фраза (StopStt(cancel=false) с текстом → SttDone): реальный Silero VAD
// а нативная библиотека sherpa-onnx есть ТОЛЬКО под Android-bionic — в JVM-юните её нет, * (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель
// и фейк падает UnsatisfiedLinkError (неремонтируемо). Сценарий покрывается на устройстве. * даёт «привет» на каждый сегмент → очки получают 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)
}
}