stt(phone): полный текст по клику — whisper на всём буфере, VAD только для промежуточных фраз

This commit is contained in:
2026-08-24 22:11:08 +03:00
parent e21c27b0ee
commit c222a4ac76
3 changed files with 299 additions and 2 deletions
@@ -29,6 +29,11 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
* Whisper (в отдельном потоке) → колбэк [onPhrase].
*
* Весь PCM сессии дополнительно накапливается без зависимости от VAD и в
* [finish] (по клику) распознаётся одним вызовом (хвост 30 с, если больше),
* перезаписывая VAD-текст; VAD-фразы остаются только промежуточными
* ([onPhrase]).
*
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
*
@@ -45,6 +50,12 @@ class SttStreamer(
companion object {
private const val SILENCE_TIMEOUT_MS = 30_000L
private const val WINDOW_SIZE = 512
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
private const val SAMPLE_RATE = 16_000
/** Максимум PCM для распознания по клику: 30 с (дольше — последние 30 с). */
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 30
}
private val lock = java.util.concurrent.locks.ReentrantLock()
@@ -79,6 +90,12 @@ class SttStreamer(
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
private var pending = FloatArray(0)
/**
* Весь PCM сессии (FloatArray @16 кГц) — не зависит от VAD-сегментации:
* [finish] прогоняет его через распознаватель одним вызовом.
*/
private var sessionPcm: FloatArray = FloatArray(0)
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
private var lastFuture: Future<*>? = null
@@ -103,6 +120,7 @@ class SttStreamer(
lock.withLock {
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
val floats = s16leToFloat(pcm)
sessionPcm = sessionPcm + floats
var i = 0
if (pending.isNotEmpty()) {
@@ -134,8 +152,11 @@ class SttStreamer(
/**
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после
* вызова нужно [reset] (состояние сессии снимает вызывающий).
* распознаваний VAD, затем распознать весь буфер сессии одним вызовом
* (хвост 30 с, если длиннее) — [full] перезаписывается результатом
* (не конкатенация с VAD-фразами). Вернуть полный текст.
* Буферы НЕ сбрасывает — после вызова нужно [reset] (состояние
* сессии снимает вызывающий).
*/
fun finish(): String {
lock.withLock {
@@ -148,9 +169,40 @@ class SttStreamer(
}
val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
recognizeFullBufferLocked()
return full
}
}
/**
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком,
* если ≤ 30 с, иначе последние 30 с (команда перед кликом точно влезает).
* Результат перезаписывает [full] — VAD-фразы идут только в [onPhrase].
* Вызывать с удержанным [lock].
*/
private fun recognizeFullBufferLocked() {
val buffer = sessionPcm
if (buffer.isEmpty()) return
val chunk = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
} else buffer
val sessionId = session
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}")
}
}
lastFuture = task
runCatching { task.get(30, TimeUnit.SECONDS) }
}
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
fun reset() {
lock.withLock { resetLocked() }
@@ -179,6 +231,7 @@ class SttStreamer(
session++
full = ""
pending = FloatArray(0)
sessionPcm = FloatArray(0)
deadlineMs = 0
}
@@ -0,0 +1,193 @@
package pw.binom.viewmate.phone.stt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertTrue
/**
* Полный текст по клику (TASK-full-buffer-stt.md): [SttStreamer] накапливает
* весь PCM сессии независимо от VAD и [SttStreamer.finish] прогоняет буфер
* (или хвост 30 с) одним вызовом [PhraseRecognizer], перезаписывая text.
* VAD-путь (промежуточные фразы в [SttStreamer.onPhrase]-колбэк) не тронут.
*/
class FullBufferSttTest {
/**
* Простейший [StreamingVad]: окна копятся, сегментов нет — кроме [flush],
* который (если включён) выдаёт один сегмент из всех накопленных окон.
*/
private class FakeVad(private val segmentOnFlush: Boolean = false) : StreamingVad {
override val windowSizeSamples = 512
private val windowed = ArrayList<Float>()
private var segment: VadSegment? = null
override fun acceptWindow(window: FloatArray) {
for (s in window) windowed += s
}
override fun popSegment(): VadSegment? {
val s = segment
segment = null
return s
}
override fun flush() {
if (segmentOnFlush && windowed.isNotEmpty()) {
segment = VadSegment(0, windowed.toFloatArray())
}
windowed.clear()
}
override fun close() {}
}
/**
* Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
* по одному на вызов (последний — на все лишние). [calls] читается
* после [SttStreamer.finish] — executor к этому моменту уже отработал
* все задачи (finish дожидается их через Future).
*/
private class RecordingRecognizer(vararg val responses: String) : PhraseRecognizer {
val calls = mutableListOf<FloatArray>()
private var count = 0
override fun recognize(samples: FloatArray): String {
calls.add(samples)
val answer = responses[minOf(count, responses.size - 1)]
count += 1
return answer
}
}
/** s16le-чанк: [count] сэмплов с глобальных индексов [offset] (зубчатая волна, период 32768). */
private fun pcmChunk(offset: Int, count: Int): ByteArray {
val out = ByteArray(count * 2)
var i = 0
while (i < count) {
val u = (offset + i) % 32768
out[2 * i] = (u and 0xff).toByte()
out[2 * i + 1] = ((u ushr 8) and 0xff).toByte()
i += 1
}
return out
}
/** Точная обратная сторона [s16leToFloat] для нашей пачки (по глобальному индексу). */
private fun expectedFloat(globalIndex: Int): Float =
(globalIndex % 32768) / 32768f
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
private fun acceptShortSession(streamer: SttStreamer) {
var off = 0
repeat(3) {
streamer.accept(pcmChunk(off, 1600))
off += 1600
}
}
/** Короткая сессия (0.3 с): finish → ОДИН вызов на весь буфер (4800 сэмплов), full = текст. */
@Test
fun shortBufferIsRecognizedInOneCall() {
val recognizer = RecordingRecognizer("привет")
val streamer = SttStreamer(
stt = recognizer,
vad = FakeVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
try {
acceptShortSession(streamer)
val full = streamer.finish()
assertEquals(1, recognizer.calls.size, "один вызов на весь буфер, без VAD-сегментации")
assertEquals(4800, recognizer.calls[0].size)
assertContentEquals(FloatArray(4800) { expectedFloat(it) }, recognizer.calls[0])
assertEquals("привет", full)
} finally {
streamer.close()
}
}
/** 40 с > 30 с: finish → распознаётся ровно хвост 16000*30 сэмплов, текст не пуст. */
@Test
fun longBufferPassesLastThirtySeconds() {
val recognizer = RecordingRecognizer("ок")
val streamer = SttStreamer(
stt = recognizer,
vad = FakeVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
try {
val total = 40 * 16_000
var off = 0
while (off < total) {
streamer.accept(pcmChunk(off, 1600))
off += 1600
}
val full = streamer.finish()
assertEquals(1, recognizer.calls.size)
val chunk = recognizer.calls[0]
assertEquals(16_000 * 30, chunk.size, "ровно хвост 30 с")
assertContentEquals(
FloatArray(16_000 * 30) { expectedFloat(total - 16_000 * 30 + it) },
chunk,
"хвост, а не начало буфера",
)
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
} finally {
streamer.close()
}
}
/** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
@Test
fun resetThenFinishSkipsRecognizer() {
val recognizer = RecordingRecognizer("привет")
val streamer = SttStreamer(
stt = recognizer,
vad = FakeVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
try {
acceptShortSession(streamer)
streamer.reset()
val full = streamer.finish()
assertEquals(0, recognizer.calls.size, "после reset буфер пуст — whisper не вызывается")
assertEquals("", full)
} finally {
streamer.close()
}
}
/** VAD-фразы по-прежнему бродкастятся; full после finish = результат полного буфера (не конкатенация). */
@Test
fun vadPhrasesAreBroadcastAndFullIsOverwritten() {
val recognizer = RecordingRecognizer("фраза", "весь буфер")
val phrases = mutableListOf<Pair<String, String>>()
val streamer = SttStreamer(
stt = recognizer,
vad = FakeVad(segmentOnFlush = true),
onPhrase = { phrase, full -> phrases += phrase to full },
onSilence30s = { },
)
try {
acceptShortSession(streamer)
val full = streamer.finish()
// Промежуточная VAD-фраза доехала до колбэка.
assertEquals(1, phrases.size)
assertEquals("фраза", phrases[0].first)
// Полный прогон: второй вызов на все сессионные 4800 сэмплов.
assertEquals(2, recognizer.calls.size)
assertEquals(4800, recognizer.calls[1].size)
// full = результат полного буфера, а не «фраза весь буфер».
assertEquals("весь буфер", full)
} finally {
streamer.close()
}
}
}