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.
|
||||
|
||||
Reference in New Issue
Block a user