stt(mic): вынести кадровую арифметику в micChunkBytes + регрессионные тесты MicChunkTest

- CHUNK_BYTES=3200, micChunkBytes(buf, frames): кадры read() -> байты (*2)
- 4 теста: полный чанк 3200 Б без копирования, короткий read,
  документация старого бага (copyOf(кадры) = полка), inFrameCount=1600
This commit is contained in:
2026-08-24 12:23:33 +03:00
parent fc84ba16b6
commit 40edd88c1d
2 changed files with 65 additions and 8 deletions
@@ -58,16 +58,11 @@ class SttMicStream(
try { try {
recorder.startRecording() recorder.startRecording()
log("stt", "микрофон запущен (16 кГц, s16, mono)") log("stt", "микрофон запущен (16 кГц, s16, mono)")
val buf = ByteArray(3_200) // = 1600 кадров (16-бит моно) = 100 мс val buf = ByteArray(CHUNK_BYTES) // = 1600 кадров (16-бит моно) = 100 мс
while (isActive && shouldRun()) { while (isActive && shouldRun()) {
// AudioRecord.read(byte[], int, inFrameCount): 3-й аргумент — В КАДРАХ, val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ
// а НЕ в байтах (16-бит моно: кадр = 2 байта). Просим buf.size/2 кадров
// (= buf.size байт = 100 мс), получаем frames — и переводиМ В БАЙТЫ (×2).
// Старый баг: n (кадры) брался как байты → ушла половина куска.
val frames = recorder.read(buf, 0, buf.size / 2)
if (frames > 0) { if (frames > 0) {
val bytes = frames * 2 val chunk = micChunkBytes(buf, frames)
val chunk = if (bytes == buf.size) buf else buf.copyOf(bytes)
if (dumpWriter != null) { if (dumpWriter != null) {
runCatching { dumpWriter.write(chunk) } runCatching { dumpWriter.write(chunk) }
.onFailure { log("stt", "dump микрофона: ${it.message}") } .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). */ /** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray { internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
val out = ByteArray(samples.size * 2) val out = ByteArray(samples.size * 2)
@@ -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
}