stt(phone): сегментация буфера на фразы перед whisper (лимит ≤30с) — длинные сессии распознаются целиком, фразы склеиваются пробелом (opencode по ТЗ)
This commit is contained in:
@@ -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<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) }
|
||||
}
|
||||
|
||||
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
||||
|
||||
Reference in New Issue
Block a user