From 0d79b5aa33ce9e95ec119686e85f24b0d1c199fe Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 20:57:06 +0300 Subject: [PATCH] =?UTF-8?q?stt(mic):=20=D1=83=D1=81=D0=B8=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B8=D0=BA=D1=80=D0=BE=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D1=87=D0=BA=D0=BE=D0=B2=20=C3=9710=20?= =?UTF-8?q?=E2=80=94=20VAD/Whisper=20=D0=B2=D0=B8=D0=B4=D1=8F=D1=82=20?= =?UTF-8?q?=D1=80=D0=B5=D1=87=D1=8C=20(=D0=BF=D1=83=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=84=D1=80=D0=B0=D0=B7=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK-mic-gain.md | 82 +++++++++++++++++++ .../viewmate/glasses/stt/SttMicStream.kt | 35 +++++++- .../viewmate/glasses/stt/MicChunkTest.kt | 73 +++++++++++++++++ 3 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 TASK-mic-gain.md diff --git a/TASK-mic-gain.md b/TASK-mic-gain.md new file mode 100644 index 0000000..8b7a533 --- /dev/null +++ b/TASK-mic-gain.md @@ -0,0 +1,82 @@ +# TASK-mic-gain.md — усиление микрофона очков (фикс «пустой фразы») + +## Симптом (пользователь, 24.08 вечер) + +Очки: TripleClick → «слушаю» → пользователь говорит → Click («отправить фразу») → очки показывают «думаю...» и СРАЗУ возвращаются в «слушаю». Ответа нет. Впечатление: «процесс говорения сломан». + +## Диагноз (подтверждён дампами и логами 24.08) + +Цепь: очки `SttMicStream` (AudioRecord MIC 16 кГц s16 mono) → BT SPP → телефон `SttStreamer` (Silero VAD → sherpa whisper-small) → `StopStt(cancel=false)` → `GlassesServer` → `full.isBlank()` → `SttCancel(REASON_USER)` → очки возвращаются в «слушаю». + +Факты: +1. **Жесты/протокол/цикл здоровы** — симуляция `DEBUG_GESTURE` (triple_click → click) воспроизводит симптом, очки логируют `Click → отправить фразу`, `stt отменено — возврат в «слушаю» (SttCancel)`. Это штатное поведение для ПУСТОЙ фразы. +2. **Микрофон RayNeo X2 пишет сигнал пиком 1–9% шкалы** (пики 181–2966 из 32768; RMS 9–184). Дампы `stt_dump_1930*.wav` (19:30, голос пользователя): после нормализации faster-whisper-base распознаёт «тихо тихо», «спокойная музыка» — РЕЧЬ есть, но амплитуда ничтожна. +3. **Транспорт очки→телефон чист** (analyze.py: порядок окон 98%, лаг 0, объём совпадает) — телефон получает тот же тихий PCM. +4. **STT-пайплайн здоров** — на эмуляторе (WAV нормального уровня) распознавание работает; на амплитудах ~0.005–0.09 Silero VAD/whisper-small не видят речи → `full` пуст → пустой клик → возврат в «слушаю». +5. Вчерашний фикс кадров/байтов (`c940a7e`+`fc84ba1`+`40edd88`) правильный и НЕ трогается — он убрал «через один», но уровень микрофона не поднял. + +**Корень: на пути аудио очки→телефон нет усиления. Микрофон тихий, VAD/Whisper не видят речь.** + +## Задача + +Добавить **фиксированное цифровое усиление** в `app-glasses` `SttMicStream.kt`, в живой ветке микрофона: чанк после `micChunkBytes(buf, frames)` усилить в `MIC_GAIN` раз (с clamp), и уже усиленный чанк отправлять `onChunk(...)` и писать в дамп. + +- Точка правки: `app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt` +- Тесты: `app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt` (существующий файл, добавить тесты) +- НЕ трогать: `HostToGlasses.kt`, `GlassesServer.kt`, `HostConnection.kt`, `MainActivity.kt` (охраняемые 4 файла фикса `7557d72`), телефонную сторону (`app-phone`), протокол (`lib-core`), `WavWriter.kt`, существующую функцию `micChunkBytes` и её тесты. +- `SttMicStream.kt` писать можно (не в охране). + +## Реализация + +В `SttMicStream.kt`, рядом с `micChunkBytes`: + +```kotlin +/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */ +internal const val MIC_GAIN = 10f + +/** + * Цифровое усиление чанка s16le mono с насыщением. + * Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767). + * Размер чанка не меняется (3200 байт = 1600 сэмплов). + */ +internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray +``` + +Разбор s16le: младший байт + (старший << 8), знак как в `s16leToFloat` (app-phone) — `if (s >= 0x8000) s -= 0x10000`. + +В цикле записи (живая ветка, после фикса `40edd88`): + +```kotlin +val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) +if (frames > 0) { + val raw = micChunkBytes(buf, frames) + val chunk = gainChunk(raw) // ← усиление здесь + if (dumpWriter != null) runCatching { dumpWriter.write(chunk) }.onFailure { ... } + onChunk(chunk) +} +``` + +**ВАЖНО: дамп (`WavWriter`) пишет УСИЛЕННЫЙ чанк** (то же, что уходит на телефон) — чтобы дампы очков отражали то, что реально получает STT. (Решение принято: диагностика по дампам очков = тому, что видит телефон; сырой микрофон больше не пишем.) + +Обоснование gain = 10: лучший утренний пик речи 2966 (9.05%) → 29660 (90.5%); тихая фраза 483 (1.5%) → 4830 (14.7%) — уже видно VAD. При крике возможен клиппинг — приемлемо (лучше клиппинг, чем пустота). Если на железе VAD захочет ложно срабатывать — константу подстроим. + +## Тесты (добавить в MicChunkTest.kt) + +1. `gainChunk` тихого чанка (сэмплы ±1000): пик результата ≥ 9000, размер = 3200 байт. +2. `gainChunk` громкого чанка (сэмплы ±30000): ни один сэмпл не выходит за ±32767 (clamp работает). +3. `gainChunk` нулевого чанка: байты без изменений. +4. `gainChunk` с gain = 1f: байты побайтово идентичны входу (для gain=1 не аллоцировать заново — вернуть `bytes`). +5. Сохранение порядка: `gainChunk` не перемешивает сэмплы (знак/порядок сохраняется — проверка первых 2 сэмплов). + +## Проверка + +1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные (было 95, станет 95 + новые). +2. Полная сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`. +3. Коммит отдельный: `stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза)`. **push НЕ делать** — приёмка на железе. + +## Приёмка на железе (делает Hermes + пользователь, НЕ агент) + +- Установить оба APK (очки + телефон) с этого коммита, force-stop, `am start`. +- Маркеры дампов `stt_dump.marker` уже лежат на обоих устройствах — дампы пишутся. +- Пользователь: TripleClick → говорит «поставь спокойную музыку» → Click. +- Hermes снимает: дампы очков (пик речи должен стать ~50–90%), `stt_dump_phone.raw` (телефон получил усиленный PCM), logcat: `SttDone` с непустым текстом → LLM-запрос → ответ на очки. diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt index 94a8914..6b95d0a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt @@ -7,6 +7,7 @@ import java.io.File import java.io.FileInputStream import java.time.LocalTime import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -29,7 +30,7 @@ class SttMicStream( ) { private var job: Job? = null - /** Debug-дампер сырого микрофона (открывается по маркеру stt_dump.marker). */ + /** Debug-дампер микрофона: пишет УСИЛЕННЫЕ чанки — те же, что уходят на телефон (по маркеру stt_dump.marker). */ private var dump: WavWriter? = null fun start(filesDir: File, shouldRun: () -> Boolean) { @@ -62,7 +63,8 @@ class SttMicStream( while (isActive && shouldRun()) { val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ if (frames > 0) { - val chunk = micChunkBytes(buf, frames) + val raw = micChunkBytes(buf, frames) + val chunk = gainChunk(raw) // усиление: в дамп и на телефон — один и тот же чанк if (dumpWriter != null) { runCatching { dumpWriter.write(chunk) } .onFailure { log("stt", "dump микрофона: ${it.message}") } @@ -91,7 +93,7 @@ class SttMicStream( /** * Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker, - * открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда + * открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда * до отправки в onChunk (эмуляторную ветку не трогаем). */ private fun openLiveMicDump(filesDir: File): WavWriter? { @@ -152,6 +154,33 @@ internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray { return if (bytes == buf.size) buf else buf.copyOf(bytes) } +/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */ +internal const val MIC_GAIN = 10f + +/** + * Цифровое усиление чанка s16le mono с насыщением. + * Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767). + * Размер чанка не меняется (3200 байт = 1600 сэмплов). + * Для gain = 1 не аллоцируем заново — возвращаем исходный массив. + */ +internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray { + if (gain == 1f) return bytes + val out = ByteArray(bytes.size) + var i = 0 + var j = 0 + while (i + 1 < bytes.size) { + val lo = bytes[i].toInt() and 0xff + val hi = bytes[i + 1].toInt() and 0xff + i += 2 + var s = lo or (hi shl 8) + if (s >= 0x8000) s -= 0x10000 + val v = (s * gain).roundToInt().coerceIn(-32_768, 32_767) + out[j++] = (v and 0xff).toByte() + out[j++] = ((v shr 8) and 0xff).toByte() + } + return out +} + /** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */ internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray { val out = ByteArray(samples.size * 2) diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt index dddd343..2343315 100644 --- a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt @@ -1,9 +1,11 @@ package pw.binom.viewmate.glasses.stt +import kotlin.math.abs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotSame import kotlin.test.assertSame +import kotlin.test.assertTrue /** * Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт. @@ -44,5 +46,76 @@ class MicChunkTest { assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)") } + /* + * Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы, + * gainChunk ×10 поднимает речь до уровня, который видят Silero VAD/Whisper. + */ + + @Test + fun quietChunk_gained10_peakReachesAtLeast9000_sizeKept() { + val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%) + val out = gainChunk(chunk) + assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт") + val peak = s16Samples(out).maxOf { abs(it) } + assertTrue(peak >= 9_000, "тихий пик 1000 × 10 должен дать ≥ 9000, а не $peak") + } + + @Test + fun loudChunk_gained10_clampedInsideS16Range() { + val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий + val out = gainChunk(chunk) + val s = s16Samples(out) + assertEquals(32_767, s.max(), "30000 × 10 = 300000 → насыщение до 32767") + assertEquals(-32_768, s.min(), "-30000 × 10 = -300000 → насыщение до -32768") + assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон") + } + + @Test + fun zeroChunk_gained10_bytesUnchanged() { + val chunk = ByteArray(CHUNK_BYTES) + val out = gainChunk(chunk) + assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер") + assertTrue(out.all { it == 0.toByte() }, "нули остаются нулями после усиления") + } + + @Test + fun gainEqualsOne_returnsSameInstance_noReallocation() { + val chunk = s16leChunk(1_600) { j -> (j % 3_001) - 1_500 } + assertSame(chunk, gainChunk(chunk, 1f), "gain = 1: не аллоцируем заново — возвратим сами bytes") + } + + @Test + fun orderPreserved_signKept_firstTwoSamplesScaled() { + val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } } + val s = s16Samples(gainChunk(chunk)) + assertEquals(-1_230, s[0], "первый сэмпл: знак сохранён и усилен ×10") + assertEquals(4_560, s[1], "второй сэмпл: порядок сохранён и усилен ×10") + } + + /** n сэмплов s16le (low byte + high<<8) из функции от индекса. */ + private fun s16leChunk(n: Int, make: (Int) -> Int): ByteArray { + val out = ByteArray(n * 2) + for (j in 0 until n) { + val v = make(j) + out[j * 2] = (v and 0xff).toByte() + out[j * 2 + 1] = ((v shr 8) and 0xff).toByte() + } + return out + } + + /** s16le ByteArray → IntArray сэмплов (разбор как в s16leToFloat app-phone). */ + private fun s16Samples(bytes: ByteArray): IntArray { + val n = bytes.size / 2 + val out = IntArray(n) + for (j in 0 until n) { + val lo = bytes[j * 2].toInt() and 0xff + val hi = bytes[j * 2 + 1].toInt() and 0xff + var s = lo or (hi shl 8) + if (s >= 0x8000) s -= 0x10000 + out[j] = s + } + return out + } + private fun recorderWouldReturn(frames: Int) = frames }