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