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
@@ -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 с по
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
* &lt; 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<FloatArray> {
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<Phrase>()
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<FloatArray>()
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<String>()
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) }
}
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
@@ -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() {
@@ -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)
}
}