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 6b95d0a..8e4f42e 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 @@ -38,10 +38,10 @@ class SttMicStream( val recorder = runCatching { AudioRecord( MediaRecorder.AudioSource.MIC, - 16_000, + MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, - 6_400, + MIC_BUFFER_BYTES, ) }.getOrNull() @@ -58,13 +58,17 @@ class SttMicStream( job = scope.launch(Dispatchers.IO) { try { recorder.startRecording() - log("stt", "микрофон запущен (16 кГц, s16, mono)") - val buf = ByteArray(CHUNK_BYTES) // = 1600 кадров (16-бит моно) = 100 мс + log("stt", "микрофон запущен ($MIC_SAMPLE_RATE Гц, s16, mono)") + val buf = ByteArray(MIC_CHUNK_FRAMES * 2) // 100 мс при 48 кГц while (isActive && shouldRun()) { - val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ + val frames = recorder.read(buf, 0, MIC_CHUNK_FRAMES) // inFrameCount — КАДРЫ if (frames > 0) { val raw = micChunkBytes(buf, frames) - val chunk = gainChunk(raw) // усиление: в дамп и на телефон — один и тот же чанк + // 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}") } @@ -143,6 +147,32 @@ class SttMicStream( /** 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 + +/** + * 48 кГц → 16 кГц: каждый 3-й сэмпл s16le mono. frames — число кадров, + * не кратных 3 — хвост отбрасывается. На выходе — байты s16le 16 кГц. + */ +internal fun downsample48To16(buf: ByteArray, frames: Int = buf.size / 2): ByteArray { + val usable = frames - (frames % 3) + val out = ByteArray(usable / 3 * 2) + var i = 0 + var k = 0 + while (k < usable * 2) { + out[i++] = buf[k] + out[i++] = buf[k + 1] + k += 6 + } + return 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 2343315..b7f8dae 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 @@ -118,4 +118,36 @@ class MicChunkTest { } private fun recorderWouldReturn(frames: Int) = frames + + /* + * Даунсемплинг 48→16 кГц (TASK-mic-48k): микрофон ARGT78 на 16 кГц пишет + * рвано (ровные провалы тишины с периодом 100 мс) — пишем на 48 кГц + * (нативная частота драйвера) и прореживаем каждый 3-й сэмпл. + */ + + @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 байт") + } + + @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 байта") + 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) + } + + @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 сэмпла") + } }