197 lines
14 KiB
Markdown
197 lines
14 KiB
Markdown
# 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` (батч) не трогаем вообще.
|