Files
view-mate/TASK-vad-interface.md

14 KiB
Raw Permalink Blame History

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

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)

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, правка минимальная)

Было:

val s = SttStreamer(stt = whisper, vadModelPath = vadModel.absolutePath, onPhrase=..., onSilence30s=...)

Стало:

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

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) на тест:

@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 (батч) не трогаем вообще.