8.3 KiB
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 с), склеивать результаты через ПРОБЕЛ (решение пользователя, дословно: «пробелами надо разделять»).
Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt— новая чистая функцияsplitPhrases+ правкаrecognizeFullBufferLocked.app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt— НОВЫЙ файл, JVM-тестыsplitPhrases.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).
Новый поток:
val chunk = trimEdges(base)— как сейчас, НЕ менять.val phrases = splitPhrases(chunk).phrasesпуст →return(как сейчас при пустом chunk).- В executor-задаче: для КАЖДОЙ фразы последовательно
stt.recognize(phrase); результат сisBlank()— пропускать (мусорный сегмент, в текст не вставлять); итог —joinToString(" ")по непустым результатам. - Все фразы пустые →
fullНЕ перезаписывать (семантика finish без текста сохраняется, вызывающий сам решит). - Лог:
полный буфер: N фраз (2.3с, 4.1с, …) → '$full'— размеры фраз в секундах, один знак после запятой. task.get(...): таймаут поднять с 30 до 60 секунд (N фраз × до 30 с каждая).
3. Тесты
PhraseSplitTest.kt — чистые JVM-тесты (синтез сигнала в стиле FullBufferSttTest: зубчатая волна как «речь», нули как тишина; для «тихих начал» — плавное нарастание амплитуды):
- Две фразы (по ~2 с), пауза 0.7 с → ровно 2 элемента; обе начинаются с звучного окна (не с тишины).
- Пауза 0.4 с (< 0.6) НЕ разделяет → 1 элемент.
- Тихие начала слов не режутся: фраза с окнами 0.003–0.009 на старте остаётся целой (порог 0.005) → 1 элемент, начало фразы включает тихие окна.
- Всё тишина → пустой список.
- Пустой вход → пустой список.
- Фраза 31 с → разрезана на 2 куска, каждый ≤ 30 с.
- Две короткие фразы (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чистый, коммит с реализацией и этим ТЗ есть.