From 40edd88c1d53ab9f2da3cca0857b2ced74047823 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 12:23:33 +0300 Subject: [PATCH] =?UTF-8?q?stt(mic):=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BA=D0=B0=D0=B4=D1=80=D0=BE=D0=B2=D1=83=D1=8E?= =?UTF-8?q?=20=D0=B0=D1=80=D0=B8=D1=84=D0=BC=D0=B5=D1=82=D0=B8=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B2=20micChunkBytes=20+=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20MicChunkTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHUNK_BYTES=3200, micChunkBytes(buf, frames): кадры read() -> байты (*2) - 4 теста: полный чанк 3200 Б без копирования, короткий read, документация старого бага (copyOf(кадры) = полка), inFrameCount=1600 --- .../viewmate/glasses/stt/SttMicStream.kt | 25 ++++++---- .../viewmate/glasses/stt/MicChunkTest.kt | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt 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 b05ee88..94a8914 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 @@ -58,16 +58,11 @@ class SttMicStream( try { recorder.startRecording() log("stt", "микрофон запущен (16 кГц, s16, mono)") - val buf = ByteArray(3_200) // = 1600 кадров (16-бит моно) = 100 мс + val buf = ByteArray(CHUNK_BYTES) // = 1600 кадров (16-бит моно) = 100 мс while (isActive && shouldRun()) { - // AudioRecord.read(byte[], int, inFrameCount): 3-й аргумент — В КАДРАХ, - // а НЕ в байтах (16-бит моно: кадр = 2 байта). Просим buf.size/2 кадров - // (= buf.size байт = 100 мс), получаем frames — и переводиМ В БАЙТЫ (×2). - // Старый баг: n (кадры) брался как байты → ушла половина куска. - val frames = recorder.read(buf, 0, buf.size / 2) + val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ if (frames > 0) { - val bytes = frames * 2 - val chunk = if (bytes == buf.size) buf else buf.copyOf(bytes) + val chunk = micChunkBytes(buf, frames) if (dumpWriter != null) { runCatching { dumpWriter.write(chunk) } .onFailure { log("stt", "dump микрофона: ${it.message}") } @@ -143,6 +138,20 @@ class SttMicStream( } } +/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */ +internal const val CHUNK_BYTES = 3_200 + +/** + * AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт. + * 16-бит моно: кадр = 2 байта. Байты = frames * 2. + * Старый баг (заделан в c940a7e): return read() (кадры) сравнивали с buf.size (байты) + * — всегда ложь → copyOf(n) резал кусок пополам (терялись 2-е 50 мс каждого 100 мс). + */ +internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray { + val bytes = frames * 2 + return if (bytes == buf.size) buf else buf.copyOf(bytes) +} + /** 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 new file mode 100644 index 0000000..dddd343 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt @@ -0,0 +1,48 @@ +package pw.binom.viewmate.glasses.stt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +/** + * Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт. + * 16-бит моно: кадр = 2 байта. Старики брали return read() как байты — + * и copyOf(кадры) резал каждый 100-мс кусок пополам (терялись 2-е 50 мс). + */ +class MicChunkTest { + + @Test + fun fullChunk_all1600Frames_returnsAll3200Bytes() { + val buf = ByteArray(CHUNK_BYTES) + val out = micChunkBytes(buf, CHUNK_BYTES / 2) // 1600 кадров = полная 100 мс + assertEquals(CHUNK_BYTES, out.size, "полный чанк = 3200 байт (100 мс), а не 1600") + assertSame(buf, out, "полный чанк не копируется — реюзаем buf без аллокаций") + } + + @Test + fun shortRead_800Frames_gives1600Bytes_not800() { + val buf = ByteArray(CHUNK_BYTES) + val out = micChunkBytes(buf, 800) // 800 кадров = 1600 байт + assertEquals(1_600, out.size, "байты = кадры × 2, не равны количеству кадров") + assertNotSame(buf, out, "короткий чанк копируется, иначе хвост buf — мусор") + } + + @Test + fun oldLogic_lostSecondHalf_ofEachChunk() { + // Задокументированный баг: n (кадры) брался как байты. + val buf = ByteArray(CHUNK_BYTES) + val frames = recorderWouldReturn(1600) // полный 100-мс запрос вернул 1600 кадров + val oldChunk = if (frames == buf.size) buf else buf.copyOf(frames) // СТАРОЕ + assertEquals(1_600, oldChunk.size, "старая логика теряла 2-ю половину куска — отсюда «как через один»") + val newChunk = micChunkBytes(buf, frames) + assertEquals(3_200, newChunk.size, "новая логика отдаёт полные 100 мс") + } + + @Test + fun framesRequested_forFull100MsIs1600() { + assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)") + } + + private fun recorderWouldReturn(frames: Int) = frames +}