From 1009713a2811acfe991be7fcd97182c751bbebdd Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 25 Aug 2026 02:55:42 +0300 Subject: [PATCH] =?UTF-8?q?stt(mic):=2048=20=D0=BA=D0=93=D1=86=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20pair-view=20+=20VAD=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D1=8B=D1=80=D0=BE=D0=BC=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=20+=20=D1=83=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=C3=975=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BB=D0=B8=D0=BF?= =?UTF-8?q?=D0=BF=D0=B0=20+=20FIR-=D0=B4=D0=B0=D1=83=D0=BD=D1=81=D1=8D?= =?UTF-8?q?=D0=BC=D0=BF=D0=BB=2048=E2=86=9216?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../viewmate/glasses/stt/SttMicStream.kt | 69 ++++++++++++++----- .../viewmate/glasses/stt/MicChunkTest.kt | 51 ++++++++++---- 2 files changed, 89 insertions(+), 31 deletions(-) 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 3b01fd9..b03608a 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 @@ -43,13 +43,17 @@ class SttMicStream( fun start(filesDir: File, shouldRun: () -> Boolean) { job?.cancel() + 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 recorder = runCatching { AudioRecord( MediaRecorder.AudioSource.MIC, - 16_000, + MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, - 6_400, + bufferBytes, ) }.getOrNull() @@ -66,10 +70,10 @@ class SttMicStream( job = scope.launch(Dispatchers.IO) { try { recorder.startRecording() - log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)") + log("stt", "микрофон запущен (48 кГц, s16, mono, WebRTC VAD на очках)") val vad = VadWebRTC( - SampleRate.SAMPLE_RATE_16K, - FrameSize.FRAME_SIZE_320, + SampleRate.SAMPLE_RATE_48K, + FrameSize.FRAME_SIZE_960, Mode.VERY_AGGRESSIVE, 600, // silenceDurationMs: тишина 600 мс = конец реплики 50, // speechDurationMs: минимум 50 мс речи @@ -83,8 +87,11 @@ class SttMicStream( 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)) { + // VAD на СЫРОМ фрейме: WebRTC VAD рассчитан на нормальный уровень + // (-21 dBFS у ARGT78 — ок), усиление ×10 клиппует и ломает детекцию. + // Усиленный фрейм — только в сегмент (для whisper и дампа). + val boosted = gainShort(data) + if (vad.isSpeech(data)) { if (!speeching) { speeching = true silenceFrames = 0 @@ -127,15 +134,15 @@ class SttMicStream( .onFailure { log("stt", "dump микрофона: ${it.message}") } } - /** Закрытый речевой сегмент: склейка фреймов → s16le → усилен в дамп и на телефон. */ + /** Закрытый речевой сегмент: склейка 48 кГц-фреймов → даунсэмпл 48→16 (с FIR) → на телефон. */ private suspend fun closeSegment(segment: List, dumpWriter: WavWriter?) { if (segment.isEmpty()) return - val pcm = shortsToPcm16(segment) + val pcm = shortsToPcm16(downsample48To16(segment)) if (dumpWriter != null) { runCatching { dumpWriter.write(pcm) } .onFailure { log("stt", "dump микрофона: ${it.message}") } } - log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 160} мс → на телефон") + log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 48} мс → на телефон") onChunk(pcm) } @@ -188,8 +195,8 @@ class SttMicStream( } } -/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов. */ -internal const val VAD_FRAME_SIZE = 320 +/** Фрейм VAD: 20 мс при 48 кГц = 960 сэмплов (как AudioRecorder в pair-view). */ +internal const val VAD_FRAME_SIZE = 960 /** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */ internal const val PRE_ROLL_FRAMES = 10 @@ -197,14 +204,17 @@ 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 +/** + * Коэффициент усиления микрофона. ×10 клипповал (ARGT78 пишет пик 1–9% → ×10 даёт + * 90%+ и 32767 на громких); ×5 — безопасно для whisper (он сам нормализует). + */ +internal const val MIC_GAIN = 5f /** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */ internal const val CHUNK_BYTES = 3_200 -/** Микрофон очков: родная 16 кГц (WebRTC VAD рекомендует 16 кГц, whisper тоже). */ -internal const val MIC_SAMPLE_RATE = 16_000 +/** Микрофон очков: 48 кГц — нативная частота, как у автора pair-view (16 кГц даёт рваные пачки у ARGT78). */ +internal const val MIC_SAMPLE_RATE = 48_000 /** * Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper). @@ -234,6 +244,33 @@ internal fun shortsToPcm16(frames: List): ByteArray { return out } +/** + * Даунсэмпл 48→16 кГц для whisper: склейка 48 кГц-фреймов, FIR (скользящее + * среднее 3) против алиасинга, берём каждый 3-й сэмпл. Длина = 1/3 исходной. + */ +internal fun downsample48To16(frames: List): List { + var total = 0 + for (f in frames) total += f.size + val outSize = total / 3 + val out = ShortArray(outSize) + var src = 0 + var dst = 0 + var prev2 = 0 + var prev1 = 0 + for (f in frames) { + for (s in f) { + if (src % 3 == 2) { + val v = ((prev2 + prev1 + s) / 3) + if (dst < outSize) out[dst++] = v.toShort() + } + prev2 = prev1 + prev1 = s.toInt() + src++ + } + } + return listOf(out) +} + /** * AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт. * 16-бит моно: кадр = 2 байта. Байты = frames * 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 6096e28..cc3ea79 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 @@ -48,30 +48,30 @@ class MicChunkTest { /* * Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы, - * gainChunk ×10 поднимает речь до уровня, который видят Silero VAD/Whisper. + * gainChunk ×5 поднимает речь для whisper; VAD детектит на сыром сигнале. */ @Test - fun quietChunk_gained10_peakReachesAtLeast9000_sizeKept() { + fun quietChunk_gained5_peakReachesAtLeast4500_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") + assertTrue(peak >= 4_500, "тихий пик 1000 × 5 должен дать ≥ 4500, а не $peak") } @Test - fun loudChunk_gained10_clampedInsideS16Range() { + fun loudChunk_gained5_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") + assertEquals(32_767, s.max(), "30000 × 5 = 150000 → насыщение до 32767") + assertEquals(-32_768, s.min(), "-30000 × 5 = -150000 → насыщение до -32768") assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон") } @Test - fun zeroChunk_gained10_bytesUnchanged() { + fun zeroChunk_gained5_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(-1_230, s[0], "первый сэмпл: знак сохранён и усилен ×10") - assertEquals(4_560, s[1], "второй сэмпл: порядок сохранён и усилен ×10") + assertEquals(-615, s[0], "первый сэмпл: знак сохранён и усилен ×5") + assertEquals(2_280, s[1], "второй сэмпл: порядок сохранён и усилен ×5") } /** n сэмплов s16le (low byte + high<<8) из функции от индекса. */ @@ -125,14 +125,14 @@ class MicChunkTest { */ @Test - fun gainShort_quietSamples_boosted10_saturated() { + fun gainShort_quietSamples_boosted5_saturated() { val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0) - val out = gainShort(samples, 10f) + val out = gainShort(samples, 5f) 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(5_000, out[0].toInt(), "1000 × 5") + assertEquals(-5_000, out[1].toInt(), "-1000 × 5") + assertEquals(32_767, out[2].toInt(), "20000 × 5 → насыщение") + assertEquals(-32_768, out[3].toInt(), "-32768 × 5 → насыщение") assertEquals(0, out[4].toInt()) } @@ -142,6 +142,27 @@ class MicChunkTest { assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует") } + @Test + fun downsample48To16_thirdLengthAndFIR() { + // 9 сэмплов 48 кГц (3 фрейма по 3) → 3 сэмпла 16 кГц + val frames = listOf(shortArrayOf(3, 6, 9), shortArrayOf(12, 15, 18), shortArrayOf(21, 24, 27)) + val out = downsample48To16(frames) + assertEquals(1, out.size) + assertEquals(3, out[0].size, "длина = 1/3") + // тройки (3,6,9) → 6; (12,15,18) → 15; (21,24,27) → 24 + assertEquals(6, out[0][0].toInt()) + assertEquals(15, out[0][1].toInt()) + assertEquals(24, out[0][2].toInt()) + } + + @Test + fun downsample48To16_shortSegment_keepsFloor() { + // 5 сэмплов → 1 (5/3 = 1) + val out = downsample48To16(listOf(shortArrayOf(1, 2, 3, 4, 5))) + assertEquals(1, out[0].size) + assertEquals(2, out[0][0].toInt(), "среднее (1,2,3)") + } + @Test fun shortsToPcm16_concatenatesLittleEndian() { val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))