stt(phone): сегментация буфера на фразы перед whisper (лимит ≤30с) — длинные сессии распознаются целиком, фразы склеиваются пробелом (opencode по ТЗ)

This commit is contained in:
2026-08-25 18:09:56 +03:00
parent ac56e70283
commit 98ac84ffe8
4 changed files with 392 additions and 22 deletions
+119
View File
@@ -0,0 +1,119 @@
# 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<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` чистый, коммит с реализацией и этим ТЗ есть.