From b17b5bf424510f1808c44e3275312c2688469183 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 25 Aug 2026 01:51:57 +0300 Subject: [PATCH] =?UTF-8?q?stt(glasses):=20VAD=20=D0=BD=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=D1=85=20(WebRTC,=20=D1=81=D1=85=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20pair-view)=20=E2=80=94=20=D1=80=D0=B5=D1=87=D0=B5?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20=D1=81=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=8B=20=D1=81=20=D0=BF=D1=80=D0=B5-=D1=80=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=BE=D0=BC,=20=D0=BC=D0=B5=D0=B6=D1=84=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=82=D0=B8=D1=88=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=88=D0=BB=D1=91=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-glasses/build.gradle.kts | 3 + .../viewmate/glasses/stt/SttMicStream.kt | 146 ++++++++++++------ .../viewmate/glasses/stt/MicChunkTest.kt | 54 ++++--- 3 files changed, 140 insertions(+), 63 deletions(-) diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts index 6ba0b17..a2102ac 100644 --- a/app-glasses/build.gradle.kts +++ b/app-glasses/build.gradle.kts @@ -61,6 +61,9 @@ dependencies { implementation(project(":lib-core")) implementation("com.squareup.okhttp3:okhttp:4.12.0") + // VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора) + implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10") + // WS-клиент очков (OkHttp — Android-движок) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.websockets) 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 8e4f42e..3b01fd9 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,6 +3,10 @@ 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 @@ -18,11 +22,15 @@ import pw.binom.viewmate.glasses.log /** * Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк. - * На эмуляторе микрофона нет — вместо него отправляется первый wav из - * filesDir/samples (отладка, см. TASK.md этап 2). * - * Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening): - * как только очки перестали слушать, поток обрывается. + * VAD живёт НА ОЧКАХ (WebRTC VAD, схема pair-view автора): микрофон читается + * фреймами по 20 мс (320 сэмплов @ 16 кГц), речевые фреймы накапливаются в + * сегмент (с пре-роллом тишины 200 мс), а наружу уходит только закрытый + * сегмент — межфразовая тишина (у микрофона ARGT78 её ~75%!) не шлётся. + * Телефон получает склеенную речь — whisper видит непрерывный звук. + * + * Список кусков/стоп — по [shouldRun]: как только очки перестали слушать, + * незакрытый сегмент отправляется (полный текст по клику). */ class SttMicStream( private val scope: CoroutineScope, @@ -30,7 +38,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) { @@ -38,10 +46,10 @@ class SttMicStream( val recorder = runCatching { AudioRecord( MediaRecorder.AudioSource.MIC, - MIC_SAMPLE_RATE, + 16_000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, - MIC_BUFFER_BYTES, + 6_400, ) }.getOrNull() @@ -58,24 +66,48 @@ class SttMicStream( job = scope.launch(Dispatchers.IO) { try { recorder.startRecording() - log("stt", "микрофон запущен ($MIC_SAMPLE_RATE Гц, s16, mono)") - val buf = ByteArray(MIC_CHUNK_FRAMES * 2) // 100 мс при 48 кГц + 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 while (isActive && shouldRun()) { - val frames = recorder.read(buf, 0, MIC_CHUNK_FRAMES) // inFrameCount — КАДРЫ - if (frames > 0) { - val raw = micChunkBytes(buf, frames) - // 48→16 кГц: каждый 3-й сэмпл (whisper/VAD ждут 16 кГц). - // Микрофон ARGT78 на 16 кГц писал рвано (ровные провалы 100 мс) — - // на 48 кГц (нативная частота драйвера) поток ожидается ровным. - val down = downsample48To16(raw) - val chunk = gainChunk(down) // усиление: в дамп и на телефон — один и тот же чанк - if (dumpWriter != null) { - runCatching { dumpWriter.write(chunk) } - .onFailure { log("stt", "dump микрофона: ${it.message}") } + val n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры + if (n <= 0) continue + val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n) + val boosted = gainShort(data) // микрофон тихий (пик 1–9%) — ×10 и для VAD, и для whisper + if (vad.isSpeech(boosted)) { + 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 мс } - onChunk(chunk) } } + if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter) } catch (t: Throwable) { log("stt", "микрофон: ${t.message}") } finally { @@ -95,10 +127,22 @@ class SttMicStream( .onFailure { log("stt", "dump микрофона: ${it.message}") } } + /** Закрытый речевой сегмент: склейка фреймов → s16le → усилен в дамп и на телефон. */ + private suspend fun closeSegment(segment: List, dumpWriter: WavWriter?) { + if (segment.isEmpty()) return + val pcm = shortsToPcm16(segment) + if (dumpWriter != null) { + runCatching { dumpWriter.write(pcm) } + .onFailure { log("stt", "dump микрофона: ${it.message}") } + } + log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 160} мс → на телефон") + onChunk(pcm) + } + /** * Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker, - * открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда - * до отправки в onChunk (эмуляторную ветку не трогаем). + * открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда + * (эмуляторную ветку не трогаем). */ private fun openLiveMicDump(filesDir: File): WavWriter? { if (!File(filesDir, "stt_dump.marker").exists()) return null @@ -144,31 +188,48 @@ class SttMicStream( } } +/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов. */ +internal const val VAD_FRAME_SIZE = 320 + +/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */ +internal const val PRE_ROLL_FRAMES = 10 + +/** Тишина 600 мс (30 фреймов) считается концом реплики. */ +internal const val SILENCE_FRAMES_TIMEOUT = 30 + +/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */ +internal const val MIC_GAIN = 10f + /** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */ internal const val CHUNK_BYTES = 3_200 -/** Микрофон очков пишем на нативной частоте драйвера (48 кГц), дальше даунсэмплим в 16 кГц. */ -internal const val MIC_SAMPLE_RATE = 48_000 - -/** 100 мс при 48 кГц: 4800 кадров. */ -internal const val MIC_CHUNK_FRAMES = 4_800 - -/** Минимальный буфер AudioRecord: 2 чанка по 100 мс. */ -internal const val MIC_BUFFER_BYTES = MIC_CHUNK_FRAMES * 2 * 2 +/** Микрофон очков: родная 16 кГц (WebRTC VAD рекомендует 16 кГц, whisper тоже). */ +internal const val MIC_SAMPLE_RATE = 16_000 /** - * 48 кГц → 16 кГц: каждый 3-й сэмпл s16le mono. frames — число кадров, - * не кратных 3 — хвост отбрасывается. На выходе — байты s16le 16 кГц. + * Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper). + * Для gain = 1 не аллоцируем заново — возвращаем исходный массив. */ -internal fun downsample48To16(buf: ByteArray, frames: Int = buf.size / 2): ByteArray { - val usable = frames - (frames % 3) - val out = ByteArray(usable / 3 * 2) +internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray { + if (gain == 1f) return samples + val out = ShortArray(samples.size) + for (i in samples.indices) { + out[i] = (samples[i] * gain).roundToInt().coerceIn(-32_768, 32_767).toShort() + } + return out +} + +/** Склейка речевых фреймов (ShortArray) в один s16le ByteArray. */ +internal fun shortsToPcm16(frames: List): ByteArray { + var total = 0 + for (f in frames) total += f.size + val out = ByteArray(total * 2) var i = 0 - var k = 0 - while (k < usable * 2) { - out[i++] = buf[k] - out[i++] = buf[k + 1] - k += 6 + for (f in frames) { + for (s in f) { + out[i++] = (s.toInt() and 0xff).toByte() + out[i++] = ((s.toInt() shr 8) and 0xff).toByte() + } } return out } @@ -184,9 +245,6 @@ 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). 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 b7f8dae..6096e28 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 @@ -120,34 +120,50 @@ class MicChunkTest { private fun recorderWouldReturn(frames: Int) = frames /* - * Даунсемплинг 48→16 кГц (TASK-mic-48k): микрофон ARGT78 на 16 кГц пишет - * рвано (ровные провалы тишины с периодом 100 мс) — пишем на 48 кГц - * (нативная частота драйвера) и прореживаем каждый 3-й сэмпл. + * VAD на очках (схема pair-view): фреймы 20 мс (320 сэмплов @ 16 кГц), + * усиление ×10 перед VAD и whisper, склейка речевых сегментов в s16le. */ @Test - fun downsample_4800Frames_gives3200Bytes() { - val buf = ByteArray(MIC_CHUNK_FRAMES * 2) // 4800 кадров = 9600 байт - val out = downsample48To16(buf, MIC_CHUNK_FRAMES) - assertEquals(3_200, out.size, "4800 кадров 48кГц → 1600 сэмплов 16кГц = 3200 байт") + fun gainShort_quietSamples_boosted10_saturated() { + val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0) + val out = gainShort(samples, 10f) + assertEquals(samples.size, out.size) + assertEquals(10_000, out[0].toInt(), "1000 × 10") + assertEquals(-10_000, out[1].toInt(), "-1000 × 10") + assertEquals(32_767, out[2].toInt(), "20000 × 10 → насыщение") + assertEquals(-32_768, out[3].toInt(), "-32768 × 10 → насыщение") + assertEquals(0, out[4].toInt()) } @Test - fun downsample_takesEveryThirdSample() { - // 6 кадров 48кГц (12 байт): 0,1,2,3,4,5 → 0 и 3 (каждый 3-й) - val buf = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) - val out = downsample48To16(buf, 6) - assertEquals(4, out.size, "6 кадров → 2 сэмпла = 4 байта") + fun gainShort_gain1_returnsSameArray() { + val samples = shortArrayOf(1, 2, 3) + assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует") + } + + @Test + fun shortsToPcm16_concatenatesLittleEndian() { + val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3)) + val out = shortsToPcm16(frames) + assertEquals(6, out.size, "3 сэмпла × 2 байта") assertEquals(1, out[0].toInt() and 0xff) - assertEquals(2, out[1].toInt() and 0xff) - assertEquals(7, out[2].toInt() and 0xff) - assertEquals(8, out[3].toInt() and 0xff) + assertEquals(0, out[1].toInt()) + assertEquals(2, out[2].toInt() and 0xff) + assertEquals(0, out[3].toInt()) + assertEquals(3, out[4].toInt() and 0xff) + assertEquals(0, out[5].toInt()) } @Test - fun downsample_nonMultipleOf3_dropsTail() { - val buf = ByteArray(14) // 7 кадров: 6 usable + 1 отброшен - val out = downsample48To16(buf, 7) - assertEquals(4, out.size, "6 usable кадров → 2 сэмпла") + fun shortsToPcm16_negativeSample_twoBytesComplement() { + val out = shortsToPcm16(listOf(shortArrayOf(-1))) + assertEquals(0xff, out[0].toInt() and 0xff) + assertEquals(0xff, out[1].toInt() and 0xff) + } + + @Test + fun shortsToPcm16_empty_returnsEmpty() { + assertEquals(0, shortsToPcm16(emptyList()).size) } }