Files
view-mate/TASK-phrase-split-stt.md

8.3 KiB
Raw Permalink Blame History

TASK: сегментация STT-буфера на фразы (whisper ≤30с)

Проблема (доказана на железе 25.08, полевым тестом ac56e70)

sherpa-onnx OfflineRecognizer (whisper-small int8) принимает волны ТОЛЬКО ≤ 30 с: лог движка «Only waves less than 30 seconds are supported. We process only the first 30 seconds and discard the remaining data». При слитном буфере сессии (полевой тест: 117.6 с после trimEdges) whisper обработал первые 30 с (тишина) и выдал галлюцинации («Убираем. Убираем…», «Хуйню ты на распознавал»), а вся речь после 30 с молча выброшена. Проверено на хосте тем же движком (sherpa-onnx 1.13.6): тот же raw, нарезанный скользящим окном 30 с (шаг 15 с), распознаётся чисто: «Я говорю что ты умеешь? Какие тебе доступны тулы? Ну давай, а что из этого тулсета у тебя есть? Раз, два, три, четыре, пять».

Решение

Перед распознаванием резать буфер на фразы по паузам тишины (≥ 0.6 с), распознавать каждую фразу отдельно (гарантированно ≤ 30 с), склеивать результаты через ПРОБЕЛ (решение пользователя, дословно: «пробелами надо разделять»).

Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)

  1. app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt — новая чистая функция splitPhrases + правка recognizeFullBufferLocked.
  2. app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt — НОВЫЙ файл, JVM-тесты splitPhrases.
  3. app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt — добавить ОДИН кейс «две фразы через паузу → распознаны раздельно, склеены пробелом» (переиспользовать приватный RecordingRecognizer того же класса).

ЗАПРЕЩЕНО трогать: SttMicStream.kt, WhisperStt.kt, trimEdges (кроме вызова), GlassesServer.kt, PhoneApp.kt, lib-core, протокол, весь app-glasses. Все scratch-файлы — внутри репо, в коммит не включать.

1. splitPhrases — чистая internal функция в SttStreamer.kt

/**
 * Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
 * Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
 * Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
 * НЕ менять без перепроверки на тех же дампах.
 */
internal fun splitPhrases(
    samples: FloatArray,
    threshold: Float = 0.005f,
    windowSize: Int = 320,          // 20 мс @ 16 кГц
    minSilenceWindows: Int = 30,    // 30 окон × 20 мс = пауза 0.6 с
    minPhraseWindows: Int = 75,     // 1.5 с — короче объединять с соседней
    maxPhraseSamples: Int = 30 * 16_000,  // страховка whisper ≤ 30 с
    padWindows: Int = 15,           // 300 мс пре/пост-ролла тишины у фразы
): List<FloatArray>

Семантика (точная, из полевого разбора):

  • Окно [windowSize] сэмплов — ТИШИНА, если RMS < threshold. Порог 0.005, НЕ 0.01 как в trimEdges: 0.01 режет тихие начала слов на обрубки, whisper на обрубке выдаёт мусор. На усиленном ×3 потоке шум ~0.005–0.009, речь ~0.03–0.14.
  • Пауза = подряд ≥ minSilenceWindows тихих окон (0.6 с). Пауза РАЗДЕЛЯЕТ фразы.
  • Фраза = звуковые окна между паузами + отступ padWindows тихих окон с каждой стороны (clamp к границам массива, как в trimEdges).
  • Фраза короче minPhraseWindows (1.5 с): объединить с СОСЕДНЕЙ фразой (короткий сегмент = обрубок; whisper на нём мусорит). Единственная фраза — оставить как есть.
  • Фраза длиннее maxPhraseSamples (30 с): резать жёстко на куски ≤ 30 с по границе окна (sherpa-onnx молча обрезает длинную волну — страховка обязательна).
  • Пустой вход / всё тишина → пустой список.
  • Внутренние паузы < 0.6 с НЕ трогать (внутри фразы тишина остаётся: склейка пауз ломает whisper — «де-тишина» давала «1,2,3,4,5,8,10», проверено).

2. recognizeFullBufferLocked — правка

Текущий код (строки ~187–211 SttStreamer.kt): trimEdges(base) → ОДИН вызов stt.recognize(chunk).

Новый поток:

  1. val chunk = trimEdges(base) — как сейчас, НЕ менять.
  2. val phrases = splitPhrases(chunk).
  3. phrases пуст → return (как сейчас при пустом chunk).
  4. В executor-задаче: для КАЖДОЙ фразы последовательно stt.recognize(phrase); результат с isBlank() — пропускать (мусорный сегмент, в текст не вставлять); итог — joinToString(" ") по непустым результатам.
  5. Все фразы пустые → full НЕ перезаписывать (семантика finish без текста сохраняется, вызывающий сам решит).
  6. Лог: полный буфер: N фраз (2.3с, 4.1с, …) → '$full' — размеры фраз в секундах, один знак после запятой.
  7. task.get(...): таймаут поднять с 30 до 60 секунд (N фраз × до 30 с каждая).

3. Тесты

PhraseSplitTest.kt — чистые JVM-тесты (синтез сигнала в стиле FullBufferSttTest: зубчатая волна как «речь», нули как тишина; для «тихих начал» — плавное нарастание амплитуды):

  1. Две фразы (по ~2 с), пауза 0.7 с → ровно 2 элемента; обе начинаются с звучного окна (не с тишины).
  2. Пауза 0.4 с (< 0.6) НЕ разделяет → 1 элемент.
  3. Тихие начала слов не режутся: фраза с окнами 0.003–0.009 на старте остаётся целой (порог 0.005) → 1 элемент, начало фразы включает тихие окна.
  4. Всё тишина → пустой список.
  5. Пустой вход → пустой список.
  6. Фраза 31 с → разрезана на 2 куска, каждый ≤ 30 с.
  7. Две короткие фразы (0.8 с и 0.9 с, пауза 0.7 с) → объединены в одну.

FullBufferSttTest.kt — добавить кейс (внутри класса, до закрывающей }):

  • буфер: «фраза А» (1.5 с зуба), пауза 0.7 с нулей, «фраза Б» (1.5 с зуба); RecordingRecognizer("А", "Б"); после finish(): calls.size == 2, full == "А Б" (пробел — разделитель по решению пользователя).

Проверка (делает Hermes, не агент)

  • ./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug — зелёные, число тестов app-phone выросло на 8 (7 новых + 1 дополненный).
  • git status --short чистый, коммит с реализацией и этим ТЗ есть.