diff --git a/TASK-phrase-split-stt.md b/TASK-phrase-split-stt.md new file mode 100644 index 0000000..6f666ba --- /dev/null +++ b/TASK-phrase-split-stt.md @@ -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 +``` + +Семантика (точная, из полевого разбора): + +- Окно [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` чистый, коммит с реализацией и этим ТЗ есть. diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt index 7aca193..a903f69 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt @@ -62,6 +62,113 @@ internal fun trimEdges( return samples.copyOfRange(start, stop) } +/** + * Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон). + * Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с). + * Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) — + * НЕ менять без перепроверки на тех же дампах. + * + * Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01 + * как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза = + * подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы. + * Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с + * каждой стороны (clamp к границам массива). Фраза короче + * [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная + * — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по + * границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы + * < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами. + */ +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 { + if (samples.isEmpty()) return emptyList() + val winCount = (samples.size + windowSize - 1) / windowSize + val isSound = BooleanArray(winCount) + var anySound = false + for (w in 0 until winCount) { + val s = w * windowSize + val e = minOf(s + windowSize, samples.size) + var sum = 0.0 + for (k in s until e) sum += samples[k] * samples[k].toDouble() + if (sqrt(sum / (e - s)).toFloat() >= threshold) { + isSound[w] = true + anySound = true + } + } + if (!anySound) return emptyList() + + // Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы + // разграничены паузами из ≥ minSilenceWindows подряд тихих окон. + class Phrase(var start: Int, var end: Int) + + val phrases = mutableListOf() + var pos = 0 + while (pos < winCount) { + var sil = 0 + while (pos + sil < winCount && !isSound[pos + sil]) sil++ + if (pos + sil >= winCount) break + val start = pos + sil + var end = start + var continuePhrase = true + while (continuePhrase) { + var run = 0 + while (end + run < winCount && isSound[end + run]) run++ + end += run + if (end >= winCount) { continuePhrase = false; break } + var sil2 = 0 + while (end + sil2 < winCount && !isSound[end + sil2]) sil2++ + if (sil2 >= minSilenceWindows) { + continuePhrase = false + } else { + end += sil2 + } + } + phrases.add(Phrase(start, end)) + pos = end + } + + // Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений). + while (phrases.size > 1) { + val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows } + if (idx < 0) break + val other = if (idx == 0) idx + 1 else idx - 1 + val lo = minOf(idx, other) + val hi = maxOf(idx, other) + val merged = Phrase( + minOf(phrases[lo].start, phrases[hi].start), + maxOf(phrases[lo].end, phrases[hi].end), + ) + phrases.removeAt(hi) + phrases.removeAt(lo) + phrases.add(lo, merged) + } + + val pad = padWindows * windowSize + val out = mutableListOf() + for (p in phrases) { + val s = maxOf(0, p.start * windowSize - pad) + val e = minOf(samples.size, p.end * windowSize + pad) + if (e - s <= maxPhraseSamples) { + out += samples.copyOfRange(s, e) + } else { + val chunk = (maxPhraseSamples / windowSize) * windowSize + var cs = s + while (cs < e) { + val ce = minOf(e, cs + chunk) + out += samples.copyOfRange(cs, ce) + cs = ce + } + } + } + return out +} + /** * Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз — * распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом @@ -178,11 +285,13 @@ class SttStreamer( } /** - * Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком, - * если ≤ [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] - * сэмплов (команда перед кликом точно влезает). Перед распознаванием - * применяется [trimEdges]. Результат перезаписывает [full]. - * Вызывать с удержанным [lock]. + * Распознать весь буфер сессии [sessionPcm]: целиком, если ≤ + * [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] сэмплов + * (команда перед кликом точно влезает). Перед распознаванием применяется + * [trimEdges], затем [splitPhrases] режет буфер на фразы по паузам — каждая + * фраза распознаётся отдельно (whisper принимает волны только ≤ 30 с), + * непустые результаты склеиваются через пробел и перезаписывают [full]. + * Все фразы мусорные → [full] НЕ трогается. Вызывать с удержанным [lock]. */ private fun recognizeFullBufferLocked() { val buffer = sessionPcm @@ -192,22 +301,32 @@ class SttStreamer( } else buffer val chunk = trimEdges(base) if (chunk.isEmpty()) return + val phrases = splitPhrases(chunk) + if (phrases.isEmpty()) return val sessionId = session + val sizes = phrases.map { "%.1fс".format(it.size / SAMPLE_RATE.toFloat()) }.joinToString(", ") val task = executor.submit { if (sessionId != session) return@submit - val result = runCatching { stt.recognize(chunk) } - if (result.isSuccess) { - full = result.getOrThrow() - log( - "stt", - "полный буфер: ${chunk.size} сэмплов (${chunk.size / SAMPLE_RATE.toFloat()}с) → '$full'", - ) - } else { - log("stt", "полный буфер: ошибка распознавания: ${result.exceptionOrNull()?.message}") + runCatching { + val parts = ArrayList() + for (ph in phrases) { + val text = stt.recognize(ph).trim() + if (text.isNotBlank()) parts.add(text) + } + if (parts.isNotEmpty()) full = parts.joinToString(" ") + parts + }.onSuccess { parts -> + if (parts.isEmpty()) { + log("stt", "полный буфер: ${phrases.size} фраз ($sizes) — без текста (мусор, full не меняю)") + } else { + log("stt", "полный буфер: ${phrases.size} фраз ($sizes) → '$full'") + } + }.onFailure { + log("stt", "полный буфер: ошибка распознавания: ${it.message}") } } lastFuture = task - runCatching { task.get(30, TimeUnit.SECONDS) } + runCatching { task.get(60, TimeUnit.SECONDS) } } /** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */ diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt index 8c103ba..6510d13 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt @@ -79,7 +79,11 @@ class FullBufferSttTest { } } - /** 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов, текст не пуст. */ + /** + * 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов (текст не пуст). + * Хвост — сплошной зубец без пауз → splitPhrases даёт одну длинную фразу (120 с), + * которая режется страховкой на куски ≤ 30 с: 4 вызова по 30 с. + */ @Test fun longBufferPassesLastTwoMinutes() { val recognizer = RecordingRecognizer("ок") @@ -92,13 +96,14 @@ class FullBufferSttTest { off += 1600 } val full = streamer.finish() - assertEquals(1, recognizer.calls.size) - val chunk = recognizer.calls[0] - assertEquals(16_000 * 120, chunk.size, "ровно хвост 120 с") + assertEquals(4, recognizer.calls.size, "120 с непрерывной речи режется на 4 фразы ≤ 30 с") + val sumLen = recognizer.calls.sumOf { it.size } + assertEquals(16_000 * 120, sumLen, "в сумме ровно хвост 120 с") + recognizer.calls.forEach { assertTrue(it.size <= 30 * 16_000, "каждая фраза ≤ 30 с") } assertContentEquals( - FloatArray(16_000 * 120) { expectedFloat(total - 16_000 * 120 + it) }, - chunk, - "хвост, а не начало буфера", + FloatArray(30 * 16_000) { expectedFloat(total - 16_000 * 120 + it) }, + recognizer.calls[0], + "первая фраза — начало хвоста, а не начало буфера", ) assertTrue(full.isNotBlank(), "результат полного буфера не пуст") } finally { @@ -106,6 +111,28 @@ class FullBufferSttTest { } } + /** + * Две фразы (1.5 с зуба каждая), пауза 0.7 с нулей между ними → распознаны + * отдельно (calls.size == 2), склеены через пробел (полный буфер = "А Б"). + */ + @Test + fun twoPhrasesSplitByPauseRecognizedSeparately() { + val recognizer = RecordingRecognizer("А", "Б") + val streamer = newStreamer(recognizer) + try { + val phraseLen = 24_000 // 1.5 с = 75 окон (= minPhraseWindows, не обрубок) + val pauseLen = 11_200 // 0.7 с нулей → ≥ 0.6 с, разделяет фразы + streamer.accept(pcmChunk(0, phraseLen)) + streamer.accept(ByteArray(pauseLen * 2)) + streamer.accept(pcmChunk(phraseLen, phraseLen)) + val full = streamer.finish() + assertEquals(2, recognizer.calls.size, "пауза 0.7 с разделила фразы") + assertEquals("А Б", full, "фразы склеены пробелом") + } finally { + streamer.close() + } + } + /** reset() → finish(): распознаватель не вызывался, полный текст пуст. */ @Test fun resetThenFinishSkipsRecognizer() { diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt new file mode 100644 index 0000000..ea3cc25 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt @@ -0,0 +1,105 @@ +package pw.binom.viewmate.phone.stt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на + * фразы по паузам тишины (≥ 0.6 с), чтобы каждая фраза (whisper ≤ 30 с) + * распознавалась отдельно. + * + * «Речь» — зубчатая волна [0,1) (RMS окна много выше порога 0.005), «тишина» — + * нули. Порог/пауза/окно по умолчанию: окно 320 = 20 мс @ 16 кГц, пауза 0.6 с + * (25…30 окон), объединение < 1.5 с, страховка ≤ 30 с. + */ +class PhraseSplitTest { + private val win = 320 + + /** Зубчатая волна [0,1): «речь» — каждое окно даёт RMS много выше порога. */ + private fun saw(offset: Int, count: Int): FloatArray = + FloatArray(count) { ((offset + it) % 32768) / 32768f } + + private fun silence(count: Int): FloatArray = FloatArray(count) + + private fun concat(vararg parts: FloatArray): FloatArray { + var total = 0 + for (p in parts) total += p.size + val out = FloatArray(total) + var i = 0 + for (p in parts) { + p.copyInto(out, i) + i += p.size + } + return out + } + + private fun hasSound(phrase: FloatArray): Boolean = phrase.any { it > 0.01f } + + /** Две фразы (~2 с), пауза 0.7 с → разделяются; обе содержат речь. */ + @Test + fun twoPhrasesSeparatedByLongPause() { + val a = saw(0, 32_000) + val pause = silence(11_200) // 0.7 с + val b = saw(32_000, 32_000) + val phrases = splitPhrases(concat(a, pause, b)) + assertEquals(2, phrases.size) + phrases.forEach { assertTrue(hasSound(it), "фраза содержит звучный участок") } + } + + /** Пауза 0.4 с (< 0.6 с) не разделяет → одна фраза. */ + @Test + fun shortPauseDoesNotSplit() { + val a = saw(0, 16_000) + val pause = silence(6_400) // 0.4 с + val b = saw(16_000, 16_000) + val phrases = splitPhrases(concat(a, pause, b)) + assertEquals(1, phrases.size) + } + + /** Тихие начала слов не режутся: порог 0.005, низкоамплитудные окна не рвут фразу. */ + @Test + fun quietWordStartIsKeptWhole() { + // 5 окон почти тишины (ниже порога) + резкий старт → содержимое остаётся одной фразой, + // тихие окна начала сохраняются в фразе (не выброшены). + val quietHead = silence(5 * win) + val loudStart = FloatArray(win) { 0.05f } + val speech = saw(0, win * 100) // 100 окон речи (2 с) — не объединяется как обрубок + val samples = concat(quietHead, loudStart, speech) + val phrases = splitPhrases(samples) + assertEquals(1, phrases.size) + assertTrue(phrases[0].size >= (5 + 1 + 100) * win, "тихие окна начала сохранены") + assertTrue(hasSound(phrases[0]), "фраза содержит речь") + } + + /** Всё тишина → пустой список. */ + @Test + fun allSilenceReturnsEmpty() { + assertEquals(0, splitPhrases(silence(48_000)).size) + } + + /** Пустой вход → пустой список. */ + @Test + fun emptyInputReturnsEmpty() { + assertEquals(0, splitPhrases(FloatArray(0)).size) + } + + /** Фраза 31 с → разрезана на куски, каждый ≤ 30 с. */ + @Test + fun phraseLongerThanThirtySecondsIsSplit() { + val long = saw(0, 31 * 16_000) + val phrases = splitPhrases(long) + assertEquals(2, phrases.size) + phrases.forEach { assertTrue(it.size <= 30 * 16_000, "кусок ≤ 30 с") } + } + + /** Две короткие фразы (0.8 с + 0.9 с, пауза 0.7 с) объединяются в одну. */ + @Test + fun shortPhrasesAreMerged() { + val a = saw(0, 12_800) // 0.8 с = 40 окон (< 75) + val pause = silence(11_200) + val b = saw(12_800, 14_400) // 0.9 с = 45 окон (< 75) + val phrases = splitPhrases(concat(a, pause, b)) + assertEquals(1, phrases.size) + } +} \ No newline at end of file