stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза)

This commit is contained in:
2026-08-24 20:57:06 +03:00
parent 40edd88c1d
commit 0d79b5aa33
3 changed files with 187 additions and 3 deletions
@@ -7,6 +7,7 @@ import java.io.File
import java.io.FileInputStream
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -29,7 +30,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) {
@@ -62,7 +63,8 @@ class SttMicStream(
while (isActive && shouldRun()) {
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ
if (frames > 0) {
val chunk = micChunkBytes(buf, frames)
val raw = micChunkBytes(buf, frames)
val chunk = gainChunk(raw) // усиление: в дамп и на телефон — один и тот же чанк
if (dumpWriter != null) {
runCatching { dumpWriter.write(chunk) }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
@@ -91,7 +93,7 @@ class SttMicStream(
/**
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
* открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда
* открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда
* до отправки в onChunk (эмуляторную ветку не трогаем).
*/
private fun openLiveMicDump(filesDir: File): WavWriter? {
@@ -152,6 +154,33 @@ 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).
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
*/
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray {
if (gain == 1f) return bytes
val out = ByteArray(bytes.size)
var i = 0
var j = 0
while (i + 1 < bytes.size) {
val lo = bytes[i].toInt() and 0xff
val hi = bytes[i + 1].toInt() and 0xff
i += 2
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
val v = (s * gain).roundToInt().coerceIn(-32_768, 32_767)
out[j++] = (v and 0xff).toByte()
out[j++] = ((v shr 8) and 0xff).toByte()
}
return out
}
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
val out = ByteArray(samples.size * 2)
@@ -1,9 +1,11 @@
package pw.binom.viewmate.glasses.stt
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
@@ -44,5 +46,76 @@ class MicChunkTest {
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)")
}
/*
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
* gainChunk ×10 поднимает речь до уровня, который видят Silero VAD/Whisper.
*/
@Test
fun quietChunk_gained10_peakReachesAtLeast9000_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")
}
@Test
fun loudChunk_gained10_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")
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
}
@Test
fun zeroChunk_gained10_bytesUnchanged() {
val chunk = ByteArray(CHUNK_BYTES)
val out = gainChunk(chunk)
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
assertTrue(out.all { it == 0.toByte() }, "нули остаются нулями после усиления")
}
@Test
fun gainEqualsOne_returnsSameInstance_noReallocation() {
val chunk = s16leChunk(1_600) { j -> (j % 3_001) - 1_500 }
assertSame(chunk, gainChunk(chunk, 1f), "gain = 1: не аллоцируем заново — возвратим сами bytes")
}
@Test
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")
}
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
private fun s16leChunk(n: Int, make: (Int) -> Int): ByteArray {
val out = ByteArray(n * 2)
for (j in 0 until n) {
val v = make(j)
out[j * 2] = (v and 0xff).toByte()
out[j * 2 + 1] = ((v shr 8) and 0xff).toByte()
}
return out
}
/** s16le ByteArray → IntArray сэмплов (разбор как в s16leToFloat app-phone). */
private fun s16Samples(bytes: ByteArray): IntArray {
val n = bytes.size / 2
val out = IntArray(n)
for (j in 0 until n) {
val lo = bytes[j * 2].toInt() and 0xff
val hi = bytes[j * 2 + 1].toInt() and 0xff
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
out[j] = s
}
return out
}
private fun recorderWouldReturn(frames: Int) = frames
}