stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user