stt(phone): полный текст по клику — whisper на всём буфере, VAD только для промежуточных фраз
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# TASK-full-buffer-stt.md — полный текст по клику: распознавание всего буфера (не VAD-сегментов)
|
||||
|
||||
## Симптом (24.08, подтверждено дампами)
|
||||
|
||||
Пользователь: TripleClick → говорит → Click → «думаю...» → сразу возврат в «слушаю». Полный текст пуст (`stt ВЕСЬ ТЕКСТ: ` пусто в логах телефона).
|
||||
|
||||
## Диагноз (по дампам голоса пользователя)
|
||||
|
||||
1. Микрофон RayNeo X2 пишет РВАНО: пачки 50–100мс с провалами до нуля (в дампе 213110, 16.7с — 66% тишины). Это железо/драйвер очков, не транспорт (очки→телефон совпадают 98%).
|
||||
2. Silero VAD (тот же `silero_vad.onnx` с телефона, прогнан на хосте) на усиленных дампах нарезает речь на 10+ сегментов по 0.3–0.4с (213110: 12 сегментов).
|
||||
3. Whisper (faster-whisper-base на хосте) на ПОЛНОМ дампе распознаёт «Ого! | А что ты делаешь?» (212441, 5.9с) и «...грека...» (213110) — НЕ пусто. На отдельных VAD-сегментах 0.3–0.4с whisper возвращает пусто/мусор.
|
||||
4. Вывод: **кликовый режим не должен зависеть от VAD-сегментации.** Начало/конец фразы задаёт пользователь кликами; полный текст = распознавание ВСЕГО накопленного PCM одним вызовом whisper. VAD остаётся только для промежуточных фраз на очки (не трогать).
|
||||
|
||||
## Задача
|
||||
|
||||
`app-phone` `SttStreamer.kt`:
|
||||
|
||||
1. **Буфер всей сессии**: в `accept(pcm)` накапливать принятые сэмплы (FloatArray) в поле `sessionPcm` (сбрасывается в `resetLocked()`). Не терять при VAD-обработке (VAD-путь остаётся как есть, параллельно).
|
||||
2. **`finish()`**: распознать полный буфер одним вызовом whisper:
|
||||
- `sessionPcm` целиком, если ≤ 30с (16000*30 сэмплов);
|
||||
- если длиннее — последние 30с (хвост: `sessionPcm.takeLast(16000*30)`), чтобы команда пользователя (сказанная перед кликом) точно попала;
|
||||
- результат: `stt.recognize(chunk)` — один вызов, текст в `full` (перезаписать, не конкатенировать с VAD-фразами).
|
||||
- `finish()` по-прежнему ждёт `lastFuture` (VAD-распознавания в executor), затем выполняет полный прогон.
|
||||
3. **`resetLocked()`**: очищать `sessionPcm`.
|
||||
4. Логи: при распознавании полного буфера `log("stt", "полный буфер: ${N} сэмплов (${dur}с) → '${text}'")`.
|
||||
|
||||
НЕ трогать: охраняемые 4 файла (`HostToGlasses.kt`, `GlassesServer.kt`, `HostConnection.kt`, `MainActivity.kt`), протокол `lib-core`, `app-glasses`, `WhisperStt`, `StreamingVad`/`SherpaStreamingVad`, VAD-путь и `onPhrase` (промежуточные фразы).
|
||||
|
||||
## Точки правки
|
||||
|
||||
- `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt` — буфер + finish() + resetLocked().
|
||||
- Тесты: новый `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt` (JVM, фейковый `PhraseRecognizer`, `FakeVad` как в существующих тестах — см. `stopSttFullTextSendsSttDone` фикстуру).
|
||||
|
||||
## Тесты (JVM)
|
||||
|
||||
1. `accept` 3 чанка по 3200Б (9600Б = 0.3с) → `finish()` → фейковый recognizer получил ОДИН вызов с полным буфером (9600 байт → 4800 сэмплов), `full` = текст фейка.
|
||||
2. Длинный буфер > 30с: `accept` 40с → `finish()` → recognizer получил ровно 16000*30 сэмплов (хвост), текст не пуст.
|
||||
3. `reset()` → `finish()` → recognizer НЕ вызывался, `full` пуст.
|
||||
4. VAD-путь не сломан: `FakeVad` с одним сегментом → `onPhrase` по-прежнему вызывается (промежуточная фраза бродкастится), но `full` после `finish()` = результат полного буфера.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `./gradlew :app-phone:testDebugUnitTest` — зелёные (было 158 + новые).
|
||||
2. Полная: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`.
|
||||
3. Коммит: `stt(phone): полный текст по клику — whisper на всём буфере, VAD только для промежуточных фраз`. Push НЕ делать.
|
||||
|
||||
## Приёмка на железе (Hermes + пользователь)
|
||||
|
||||
- Установить оба APK, force-stop, `am start`.
|
||||
- Пользователь: TripleClick → фраза («поставь спокойную музыку») → Click.
|
||||
- Ожидание: `stt ВЕСЬ ТЕКСТ: поставь спокойную музыку` (не пусто) → LLM → ответ на очки.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user