# 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 ```kotlin /** * Режет [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 ``` Семантика (точная, из полевого разбора): - Окно [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` чистый, коммит с реализацией и этим ТЗ есть.