120 lines
8.3 KiB
Markdown
120 lines
8.3 KiB
Markdown
# 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` чистый, коммит с реализацией и этим ТЗ есть.
|