From b0baa19e7db6e5d7cf5a5a14ee1a22d1f843f8d3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 25 Aug 2026 03:12:04 +0300 Subject: [PATCH] =?UTF-8?q?stt:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=20=D0=B1=D0=B5=D0=B7=20VAD=20(?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=D0=BB=20=D1=80=D0=B5=D1=87=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B2=D0=B0=D0=BD=D0=BE=D0=BC=20ARGT78)?= =?UTF-8?q?=20=E2=80=94=20whisper=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=91=D1=82=20=D1=81=D1=8B=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=20(=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BE:=201-10=20=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE),=20=D1=83=D1=81=D0=B8=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=C3=973,=20=D1=81=D1=8B=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=B0=D0=BC=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-glasses/build.gradle.kts | 4 +- .../viewmate/glasses/stt/SttMicStream.kt | 124 ++++++------------ .../viewmate/glasses/stt/MicChunkTest.kt | 16 +-- 3 files changed, 50 insertions(+), 94 deletions(-) diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts index a2102ac..ade865e 100644 --- a/app-glasses/build.gradle.kts +++ b/app-glasses/build.gradle.kts @@ -62,7 +62,9 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") // VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора) - implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10") + // VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 — + // whisper распознаёт полный поток без VAD (проверено на сыром дампе). + // implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10") // WS-клиент очков (OkHttp — Android-движок) implementation(libs.ktor.client.okhttp) 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 b99d08b..15a66a1 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 @@ -3,15 +3,6 @@ package pw.binom.viewmate.glasses.stt import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder -import com.konovalov.vad.webrtc.VadWebRTC -import com.konovalov.vad.webrtc.config.FrameSize -import com.konovalov.vad.webrtc.config.Mode -import com.konovalov.vad.webrtc.config.SampleRate -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 @@ -19,18 +10,22 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import pw.binom.viewmate.glasses.log +import java.io.File +import java.io.FileInputStream +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt /** - * Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк. + * Поток микрофона очков (TASK-mic): 16 кГц s16 mono, полный поток БЕЗ VAD-нарезки. * - * VAD живёт НА ОЧКАХ (WebRTC VAD, схема pair-view автора): микрофон читается - * фреймами по 20 мс (320 сэмплов @ 16 кГц), речевые фреймы накапливаются в - * сегмент (с пре-роллом тишины 200 мс), а наружу уходит только закрытый - * сегмент — межфразовая тишина (у микрофона ARGT78 её ~75%!) не шлётся. - * Телефон получает склеенную речь — whisper видит непрерывный звук. - * - * Список кусков/стоп — по [shouldRun]: как только очки перестали слушать, - * незакрытый сегмент отправляется (полный текст по клику). + * История: WebRTC VAD резал речь на огрызки — микрофон ARGT78 пишет рвано + * (пачки ~50 мс звука / ~50 мс тишины), VAD принимал внутренние провалы за паузы + * и ловил лишь ~15% речи. Локальный тест whisper на сыром дампе («1,2,3,4,5 + * 6,7,8,9,10») распознал полный файл идеально — whisper сам справляется с + * рваностью и тихим уровнем (пик ~1500). Поэтому: весь PCM уходит на телефон + * чанками по 100 мс, усиление ×3 (без клиппа), телефон копит полный буфер + * сессии и шлёт его в whisper по клику. */ class SttMicStream( private val scope: CoroutineScope, @@ -38,7 +33,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) { @@ -46,7 +41,7 @@ class SttMicStream( val minBuffer = AudioRecord.getMinBufferSize( MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - val bufferBytes = maxOf(minBuffer, VAD_FRAME_SIZE * 2 * 4) // ≥ 4 фрейма + val bufferBytes = maxOf(minBuffer, MIC_CHUNK_FRAMES * FRAME_SIZE * 2) val recorder = runCatching { AudioRecord( MediaRecorder.AudioSource.MIC, @@ -70,56 +65,25 @@ class SttMicStream( job = scope.launch(Dispatchers.IO) { try { recorder.startRecording() - log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)") - val vad = VadWebRTC( - SampleRate.SAMPLE_RATE_16K, - FrameSize.FRAME_SIZE_320, - Mode.VERY_AGGRESSIVE, - 600, // silenceDurationMs: тишина 600 мс = конец реплики - 50, // speechDurationMs: минимум 50 мс речи - ) - val frame = ShortArray(VAD_FRAME_SIZE) // 320 сэмплов = 20 мс - val segment = ArrayList() - val preRoll = ArrayDeque() - var speeching = false - var silenceFrames = 0 + log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)") + val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс + val chunk = ArrayList(MIC_CHUNK_FRAMES) while (isActive && shouldRun()) { - val n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры + val n = recorder.read(frame, 0, FRAME_SIZE) // кадры if (n <= 0) continue - val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n) - // СЫРОЙ дамп: весь поток микрофона (до усиления/VAD) — что реально слышат очки. + val data = if (n == FRAME_SIZE) frame else frame.copyOf(n) + // СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки. if (dumpWriter != null) { runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) } .onFailure { log("stt", "dump микрофона: ${it.message}") } } - // VAD на СЫРОМ фрейме: WebRTC VAD рассчитан на нормальный уровень - // (-21 dBFS у ARGT78 — ок), усиление ×10 клиппует и ломает детекцию. - // Усиленный фрейм — только в сегмент (для whisper). - val boosted = gainShort(data) - if (vad.isSpeech(data)) { - if (!speeching) { - speeching = true - silenceFrames = 0 - segment.clear() - while (preRoll.isNotEmpty()) segment.add(preRoll.removeFirst()) - log("stt", "🎤 речь началась (${segment.size} фреймов с пре-роллом)") - } - segment.add(boosted) - } else { - if (speeching) { - segment.add(boosted) - silenceFrames++ - if (silenceFrames >= SILENCE_FRAMES_TIMEOUT) { // 600 мс тишины - closeSegment(segment, dumpWriter) - speeching = false - } - } else { - preRoll.addLast(boosted) - if (preRoll.size > PRE_ROLL_FRAMES) preRoll.removeFirst() // 200 мс - } + chunk.add(gainShort(data, MIC_GAIN)) + if (chunk.size >= MIC_CHUNK_FRAMES) { + onChunk(shortsToPcm16(chunk)) + chunk.clear() } } - if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter) + if (chunk.isNotEmpty()) onChunk(shortsToPcm16(chunk)) // остаток < 100 мс } catch (t: Throwable) { log("stt", "микрофон: ${t.message}") } finally { @@ -139,17 +103,9 @@ class SttMicStream( .onFailure { log("stt", "dump микрофона: ${it.message}") } } - /** Закрытый речевой сегмент: склейка 16 кГц-фреймов → s16le → на телефон. */ - private suspend fun closeSegment(segment: List, dumpWriter: WavWriter?) { - if (segment.isEmpty()) return - val pcm = shortsToPcm16(segment) - log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 16} мс → на телефон") - onChunk(pcm) - } - /** * Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker, - * открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда + * открыть stt_dump_HHmmss.wav — сырой поток микрофона допишется туда * (эмуляторную ветку не трогаем). */ private fun openLiveMicDump(filesDir: File): WavWriter? { @@ -196,20 +152,18 @@ class SttMicStream( } } -/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */ -internal const val VAD_FRAME_SIZE = 320 +/** Фрейм чтения микрофона: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */ +internal const val FRAME_SIZE = 320 -/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */ -internal const val PRE_ROLL_FRAMES = 10 - -/** Тишина 600 мс (30 фреймов) считается концом реплики. */ -internal const val SILENCE_FRAMES_TIMEOUT = 30 +/** Чанк на телефон: 100 мс = 5 фреймов по 20 мс. */ +internal const val MIC_CHUNK_FRAMES = 5 /** - * Коэффициент усиления микрофона. ×10 клипповал (ARGT78 пишет пик 1–9% → ×10 даёт - * 90%+ и 32767 на громких); ×5 — безопасно для whisper (он сам нормализует). + * Коэффициент усиления микрофона: ×3 (без клиппа — ARGT78 пишет пик 1–9%, ×3 даёт + * 3–27%). Whisper распознал и сырой (пик 1521), и ×3, и ×10 — усиление нужно + * только чтобы телефонный whisper.cpp не терял тихие участки. */ -internal const val MIC_GAIN = 5f +internal const val MIC_GAIN = 3f /** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */ internal const val CHUNK_BYTES = 3_200 @@ -218,7 +172,7 @@ internal const val CHUNK_BYTES = 3_200 internal const val MIC_SAMPLE_RATE = 16_000 /** - * Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper). + * Цифровое усиление фрейма s16le mono с насыщением (для whisper). * Для gain = 1 не аллоцируем заново — возвращаем исходный массив. */ internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray { @@ -230,7 +184,7 @@ internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray return out } -/** Склейка речевых фреймов (ShortArray) в один s16le ByteArray. */ +/** Склейка фреймов (ShortArray) в один s16le ByteArray. */ internal fun shortsToPcm16(frames: List): ByteArray { var total = 0 for (f in frames) total += f.size @@ -246,8 +200,8 @@ internal fun shortsToPcm16(frames: List): ByteArray { } /** - * Даунсэмпл 48→16 кГц для whisper: склейка 48 кГц-фреймов, FIR (скользящее - * среднее 3) против алиасинга, берём каждый 3-й сэмпл. Длина = 1/3 исходной. + * Даунсэмпл 48→16 кГц (сохранён для тестов; в проде не используется — 16 кГц напрямую): + * склейка 48 кГц-фреймов, FIR (скользящее среднее 3) против алиасинга, каждый 3-й сэмпл. */ internal fun downsample48To16(frames: List): List { var total = 0 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 cc3ea79..feddebd 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 @@ -52,26 +52,26 @@ class MicChunkTest { */ @Test - fun quietChunk_gained5_peakReachesAtLeast4500_sizeKept() { + fun quietChunk_gained3_peakReachesAtLeast2700_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 >= 4_500, "тихий пик 1000 × 5 должен дать ≥ 4500, а не $peak") + assertTrue(peak >= 2_700, "тихий пик 1000 × 3 должен дать ≥ 2700, а не $peak") } @Test - fun loudChunk_gained5_clampedInsideS16Range() { + fun loudChunk_gained3_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 × 5 = 150000 → насыщение до 32767") - assertEquals(-32_768, s.min(), "-30000 × 5 = -150000 → насыщение до -32768") + assertEquals(32_767, s.max(), "30000 × 3 = 90000 → насыщение до 32767") + assertEquals(-32_768, s.min(), "-30000 × 3 = -90000 → насыщение до -32768") assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон") } @Test - fun zeroChunk_gained5_bytesUnchanged() { + fun zeroChunk_gained3_bytesUnchanged() { val chunk = ByteArray(CHUNK_BYTES) val out = gainChunk(chunk) assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер") @@ -88,8 +88,8 @@ class MicChunkTest { fun orderPreserved_signKept_firstTwoSamplesScaled() { val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } } val s = s16Samples(gainChunk(chunk)) - assertEquals(-615, s[0], "первый сэмпл: знак сохранён и усилен ×5") - assertEquals(2_280, s[1], "второй сэмпл: порядок сохранён и усилен ×5") + assertEquals(-369, s[0], "первый сэмпл: знак сохранён и усилен ×3") + assertEquals(1_368, s[1], "второй сэмпл: порядок сохранён и усилен ×3") } /** n сэмплов s16le (low byte + high<<8) из функции от индекса. */