stt(mic): 48 кГц как pair-view + VAD на сыром сигнале + усиление ×5 без клиппа + FIR-даунсэмпл 48→16
This commit is contained in:
@@ -43,13 +43,17 @@ class SttMicStream(
|
||||
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
job?.cancel()
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
val bufferBytes = maxOf(minBuffer, VAD_FRAME_SIZE * 2 * 4) // ≥ 4 фрейма
|
||||
val recorder = runCatching {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
16_000,
|
||||
MIC_SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
6_400,
|
||||
bufferBytes,
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -66,10 +70,10 @@ class SttMicStream(
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
recorder.startRecording()
|
||||
log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)")
|
||||
log("stt", "микрофон запущен (48 кГц, s16, mono, WebRTC VAD на очках)")
|
||||
val vad = VadWebRTC(
|
||||
SampleRate.SAMPLE_RATE_16K,
|
||||
FrameSize.FRAME_SIZE_320,
|
||||
SampleRate.SAMPLE_RATE_48K,
|
||||
FrameSize.FRAME_SIZE_960,
|
||||
Mode.VERY_AGGRESSIVE,
|
||||
600, // silenceDurationMs: тишина 600 мс = конец реплики
|
||||
50, // speechDurationMs: минимум 50 мс речи
|
||||
@@ -83,8 +87,11 @@ class SttMicStream(
|
||||
val n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры
|
||||
if (n <= 0) continue
|
||||
val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n)
|
||||
val boosted = gainShort(data) // микрофон тихий (пик 1–9%) — ×10 и для VAD, и для whisper
|
||||
if (vad.isSpeech(boosted)) {
|
||||
// VAD на СЫРОМ фрейме: WebRTC VAD рассчитан на нормальный уровень
|
||||
// (-21 dBFS у ARGT78 — ок), усиление ×10 клиппует и ломает детекцию.
|
||||
// Усиленный фрейм — только в сегмент (для whisper и дампа).
|
||||
val boosted = gainShort(data)
|
||||
if (vad.isSpeech(data)) {
|
||||
if (!speeching) {
|
||||
speeching = true
|
||||
silenceFrames = 0
|
||||
@@ -127,15 +134,15 @@ class SttMicStream(
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Закрытый речевой сегмент: склейка фреймов → s16le → усилен в дамп и на телефон. */
|
||||
/** Закрытый речевой сегмент: склейка 48 кГц-фреймов → даунсэмпл 48→16 (с FIR) → на телефон. */
|
||||
private suspend fun closeSegment(segment: List<ShortArray>, dumpWriter: WavWriter?) {
|
||||
if (segment.isEmpty()) return
|
||||
val pcm = shortsToPcm16(segment)
|
||||
val pcm = shortsToPcm16(downsample48To16(segment))
|
||||
if (dumpWriter != null) {
|
||||
runCatching { dumpWriter.write(pcm) }
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
}
|
||||
log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 160} мс → на телефон")
|
||||
log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 48} мс → на телефон")
|
||||
onChunk(pcm)
|
||||
}
|
||||
|
||||
@@ -188,8 +195,8 @@ class SttMicStream(
|
||||
}
|
||||
}
|
||||
|
||||
/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов. */
|
||||
internal const val VAD_FRAME_SIZE = 320
|
||||
/** Фрейм VAD: 20 мс при 48 кГц = 960 сэмплов (как AudioRecorder в pair-view). */
|
||||
internal const val VAD_FRAME_SIZE = 960
|
||||
|
||||
/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */
|
||||
internal const val PRE_ROLL_FRAMES = 10
|
||||
@@ -197,14 +204,17 @@ internal const val PRE_ROLL_FRAMES = 10
|
||||
/** Тишина 600 мс (30 фреймов) считается концом реплики. */
|
||||
internal const val SILENCE_FRAMES_TIMEOUT = 30
|
||||
|
||||
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
|
||||
internal const val MIC_GAIN = 10f
|
||||
/**
|
||||
* Коэффициент усиления микрофона. ×10 клипповал (ARGT78 пишет пик 1–9% → ×10 даёт
|
||||
* 90%+ и 32767 на громких); ×5 — безопасно для whisper (он сам нормализует).
|
||||
*/
|
||||
internal const val MIC_GAIN = 5f
|
||||
|
||||
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
||||
internal const val CHUNK_BYTES = 3_200
|
||||
|
||||
/** Микрофон очков: родная 16 кГц (WebRTC VAD рекомендует 16 кГц, whisper тоже). */
|
||||
internal const val MIC_SAMPLE_RATE = 16_000
|
||||
/** Микрофон очков: 48 кГц — нативная частота, как у автора pair-view (16 кГц даёт рваные пачки у ARGT78). */
|
||||
internal const val MIC_SAMPLE_RATE = 48_000
|
||||
|
||||
/**
|
||||
* Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper).
|
||||
@@ -234,6 +244,33 @@ internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Даунсэмпл 48→16 кГц для whisper: склейка 48 кГц-фреймов, FIR (скользящее
|
||||
* среднее 3) против алиасинга, берём каждый 3-й сэмпл. Длина = 1/3 исходной.
|
||||
*/
|
||||
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
|
||||
var total = 0
|
||||
for (f in frames) total += f.size
|
||||
val outSize = total / 3
|
||||
val out = ShortArray(outSize)
|
||||
var src = 0
|
||||
var dst = 0
|
||||
var prev2 = 0
|
||||
var prev1 = 0
|
||||
for (f in frames) {
|
||||
for (s in f) {
|
||||
if (src % 3 == 2) {
|
||||
val v = ((prev2 + prev1 + s) / 3)
|
||||
if (dst < outSize) out[dst++] = v.toShort()
|
||||
}
|
||||
prev2 = prev1
|
||||
prev1 = s.toInt()
|
||||
src++
|
||||
}
|
||||
}
|
||||
return listOf(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
|
||||
* 16-бит моно: кадр = 2 байта. Байты = frames * 2.
|
||||
|
||||
@@ -48,30 +48,30 @@ class MicChunkTest {
|
||||
|
||||
/*
|
||||
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
|
||||
* gainChunk ×10 поднимает речь до уровня, который видят Silero VAD/Whisper.
|
||||
* gainChunk ×5 поднимает речь для whisper; VAD детектит на сыром сигнале.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun quietChunk_gained10_peakReachesAtLeast9000_sizeKept() {
|
||||
fun quietChunk_gained5_peakReachesAtLeast4500_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")
|
||||
assertTrue(peak >= 4_500, "тихий пик 1000 × 5 должен дать ≥ 4500, а не $peak")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loudChunk_gained10_clampedInsideS16Range() {
|
||||
fun loudChunk_gained5_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")
|
||||
assertEquals(32_767, s.max(), "30000 × 5 = 150000 → насыщение до 32767")
|
||||
assertEquals(-32_768, s.min(), "-30000 × 5 = -150000 → насыщение до -32768")
|
||||
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroChunk_gained10_bytesUnchanged() {
|
||||
fun zeroChunk_gained5_bytesUnchanged() {
|
||||
val chunk = ByteArray(CHUNK_BYTES)
|
||||
val out = gainChunk(chunk)
|
||||
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
||||
@@ -88,8 +88,8 @@ class MicChunkTest {
|
||||
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")
|
||||
assertEquals(-615, s[0], "первый сэмпл: знак сохранён и усилен ×5")
|
||||
assertEquals(2_280, s[1], "второй сэмпл: порядок сохранён и усилен ×5")
|
||||
}
|
||||
|
||||
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
||||
@@ -125,14 +125,14 @@ class MicChunkTest {
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun gainShort_quietSamples_boosted10_saturated() {
|
||||
fun gainShort_quietSamples_boosted5_saturated() {
|
||||
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
|
||||
val out = gainShort(samples, 10f)
|
||||
val out = gainShort(samples, 5f)
|
||||
assertEquals(samples.size, out.size)
|
||||
assertEquals(10_000, out[0].toInt(), "1000 × 10")
|
||||
assertEquals(-10_000, out[1].toInt(), "-1000 × 10")
|
||||
assertEquals(32_767, out[2].toInt(), "20000 × 10 → насыщение")
|
||||
assertEquals(-32_768, out[3].toInt(), "-32768 × 10 → насыщение")
|
||||
assertEquals(5_000, out[0].toInt(), "1000 × 5")
|
||||
assertEquals(-5_000, out[1].toInt(), "-1000 × 5")
|
||||
assertEquals(32_767, out[2].toInt(), "20000 × 5 → насыщение")
|
||||
assertEquals(-32_768, out[3].toInt(), "-32768 × 5 → насыщение")
|
||||
assertEquals(0, out[4].toInt())
|
||||
}
|
||||
|
||||
@@ -142,6 +142,27 @@ class MicChunkTest {
|
||||
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downsample48To16_thirdLengthAndFIR() {
|
||||
// 9 сэмплов 48 кГц (3 фрейма по 3) → 3 сэмпла 16 кГц
|
||||
val frames = listOf(shortArrayOf(3, 6, 9), shortArrayOf(12, 15, 18), shortArrayOf(21, 24, 27))
|
||||
val out = downsample48To16(frames)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals(3, out[0].size, "длина = 1/3")
|
||||
// тройки (3,6,9) → 6; (12,15,18) → 15; (21,24,27) → 24
|
||||
assertEquals(6, out[0][0].toInt())
|
||||
assertEquals(15, out[0][1].toInt())
|
||||
assertEquals(24, out[0][2].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downsample48To16_shortSegment_keepsFloor() {
|
||||
// 5 сэмплов → 1 (5/3 = 1)
|
||||
val out = downsample48To16(listOf(shortArrayOf(1, 2, 3, 4, 5)))
|
||||
assertEquals(1, out[0].size)
|
||||
assertEquals(2, out[0][0].toInt(), "среднее (1,2,3)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortsToPcm16_concatenatesLittleEndian() {
|
||||
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
|
||||
|
||||
Reference in New Issue
Block a user