stt(mic): 48 кГц как pair-view + VAD на сыром сигнале + усиление ×5 без клиппа + FIR-даунсэмпл 48→16

This commit is contained in:
2026-08-25 02:55:42 +03:00
parent 0f235da0ed
commit 1009713a28
2 changed files with 89 additions and 31 deletions
@@ -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.