diff --git a/TASK-full-buffer-stt.md b/TASK-full-buffer-stt.md new file mode 100644 index 0000000..fbd7743 --- /dev/null +++ b/TASK-full-buffer-stt.md @@ -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 → ответ на очки. 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 5c3ddc8..8873139 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 @@ -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 } 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 new file mode 100644 index 0000000..db6d3a4 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt @@ -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() + 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() + 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>() + 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() + } + } +}