stt: полный поток без VAD (резал речь на рваном ARGT78) — whisper распознаёт сырой поток (проверено: 1-10 идеально), усиление ×3, сырой дамп
This commit is contained in:
@@ -3,15 +3,6 @@ package pw.binom.viewmate.glasses.stt
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import com.konovalov.vad.webrtc.VadWebRTC
|
||||
import com.konovalov.vad.webrtc.config.FrameSize
|
||||
import com.konovalov.vad.webrtc.config.Mode
|
||||
import com.konovalov.vad.webrtc.config.SampleRate
|
||||
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
|
||||
@@ -19,18 +10,22 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
|
||||
* Поток микрофона очков (TASK-mic): 16 кГц s16 mono, полный поток БЕЗ VAD-нарезки.
|
||||
*
|
||||
* VAD живёт НА ОЧКАХ (WebRTC VAD, схема pair-view автора): микрофон читается
|
||||
* фреймами по 20 мс (320 сэмплов @ 16 кГц), речевые фреймы накапливаются в
|
||||
* сегмент (с пре-роллом тишины 200 мс), а наружу уходит только закрытый
|
||||
* сегмент — межфразовая тишина (у микрофона ARGT78 её ~75%!) не шлётся.
|
||||
* Телефон получает склеенную речь — whisper видит непрерывный звук.
|
||||
*
|
||||
* Список кусков/стоп — по [shouldRun]: как только очки перестали слушать,
|
||||
* незакрытый сегмент отправляется (полный текст по клику).
|
||||
* История: WebRTC VAD резал речь на огрызки — микрофон ARGT78 пишет рвано
|
||||
* (пачки ~50 мс звука / ~50 мс тишины), VAD принимал внутренние провалы за паузы
|
||||
* и ловил лишь ~15% речи. Локальный тест whisper на сыром дампе («1,2,3,4,5
|
||||
* 6,7,8,9,10») распознал полный файл идеально — whisper сам справляется с
|
||||
* рваностью и тихим уровнем (пик ~1500). Поэтому: весь PCM уходит на телефон
|
||||
* чанками по 100 мс, усиление ×3 (без клиппа), телефон копит полный буфер
|
||||
* сессии и шлёт его в whisper по клику.
|
||||
*/
|
||||
class SttMicStream(
|
||||
private val scope: CoroutineScope,
|
||||
@@ -38,7 +33,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) {
|
||||
@@ -46,7 +41,7 @@ class SttMicStream(
|
||||
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 bufferBytes = maxOf(minBuffer, MIC_CHUNK_FRAMES * FRAME_SIZE * 2)
|
||||
val recorder = runCatching {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
@@ -70,56 +65,25 @@ class SttMicStream(
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
recorder.startRecording()
|
||||
log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)")
|
||||
val vad = VadWebRTC(
|
||||
SampleRate.SAMPLE_RATE_16K,
|
||||
FrameSize.FRAME_SIZE_320,
|
||||
Mode.VERY_AGGRESSIVE,
|
||||
600, // silenceDurationMs: тишина 600 мс = конец реплики
|
||||
50, // speechDurationMs: минимум 50 мс речи
|
||||
)
|
||||
val frame = ShortArray(VAD_FRAME_SIZE) // 320 сэмплов = 20 мс
|
||||
val segment = ArrayList<ShortArray>()
|
||||
val preRoll = ArrayDeque<ShortArray>()
|
||||
var speeching = false
|
||||
var silenceFrames = 0
|
||||
log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)")
|
||||
val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс
|
||||
val chunk = ArrayList<ShortArray>(MIC_CHUNK_FRAMES)
|
||||
while (isActive && shouldRun()) {
|
||||
val n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры
|
||||
val n = recorder.read(frame, 0, FRAME_SIZE) // кадры
|
||||
if (n <= 0) continue
|
||||
val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n)
|
||||
// СЫРОЙ дамп: весь поток микрофона (до усиления/VAD) — что реально слышат очки.
|
||||
val data = if (n == FRAME_SIZE) frame else frame.copyOf(n)
|
||||
// СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки.
|
||||
if (dumpWriter != null) {
|
||||
runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) }
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
}
|
||||
// VAD на СЫРОМ фрейме: WebRTC VAD рассчитан на нормальный уровень
|
||||
// (-21 dBFS у ARGT78 — ок), усиление ×10 клиппует и ломает детекцию.
|
||||
// Усиленный фрейм — только в сегмент (для whisper).
|
||||
val boosted = gainShort(data)
|
||||
if (vad.isSpeech(data)) {
|
||||
if (!speeching) {
|
||||
speeching = true
|
||||
silenceFrames = 0
|
||||
segment.clear()
|
||||
while (preRoll.isNotEmpty()) segment.add(preRoll.removeFirst())
|
||||
log("stt", "🎤 речь началась (${segment.size} фреймов с пре-роллом)")
|
||||
}
|
||||
segment.add(boosted)
|
||||
} else {
|
||||
if (speeching) {
|
||||
segment.add(boosted)
|
||||
silenceFrames++
|
||||
if (silenceFrames >= SILENCE_FRAMES_TIMEOUT) { // 600 мс тишины
|
||||
closeSegment(segment, dumpWriter)
|
||||
speeching = false
|
||||
}
|
||||
} else {
|
||||
preRoll.addLast(boosted)
|
||||
if (preRoll.size > PRE_ROLL_FRAMES) preRoll.removeFirst() // 200 мс
|
||||
}
|
||||
chunk.add(gainShort(data, MIC_GAIN))
|
||||
if (chunk.size >= MIC_CHUNK_FRAMES) {
|
||||
onChunk(shortsToPcm16(chunk))
|
||||
chunk.clear()
|
||||
}
|
||||
}
|
||||
if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter)
|
||||
if (chunk.isNotEmpty()) onChunk(shortsToPcm16(chunk)) // остаток < 100 мс
|
||||
} catch (t: Throwable) {
|
||||
log("stt", "микрофон: ${t.message}")
|
||||
} finally {
|
||||
@@ -139,17 +103,9 @@ class SttMicStream(
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Закрытый речевой сегмент: склейка 16 кГц-фреймов → s16le → на телефон. */
|
||||
private suspend fun closeSegment(segment: List<ShortArray>, dumpWriter: WavWriter?) {
|
||||
if (segment.isEmpty()) return
|
||||
val pcm = shortsToPcm16(segment)
|
||||
log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 16} мс → на телефон")
|
||||
onChunk(pcm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||
* открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда
|
||||
* открыть stt_dump_HHmmss.wav — сырой поток микрофона допишется туда
|
||||
* (эмуляторную ветку не трогаем).
|
||||
*/
|
||||
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||
@@ -196,20 +152,18 @@ class SttMicStream(
|
||||
}
|
||||
}
|
||||
|
||||
/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
|
||||
internal const val VAD_FRAME_SIZE = 320
|
||||
/** Фрейм чтения микрофона: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
|
||||
internal const val FRAME_SIZE = 320
|
||||
|
||||
/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */
|
||||
internal const val PRE_ROLL_FRAMES = 10
|
||||
|
||||
/** Тишина 600 мс (30 фреймов) считается концом реплики. */
|
||||
internal const val SILENCE_FRAMES_TIMEOUT = 30
|
||||
/** Чанк на телефон: 100 мс = 5 фреймов по 20 мс. */
|
||||
internal const val MIC_CHUNK_FRAMES = 5
|
||||
|
||||
/**
|
||||
* Коэффициент усиления микрофона. ×10 клипповал (ARGT78 пишет пик 1–9% → ×10 даёт
|
||||
* 90%+ и 32767 на громких); ×5 — безопасно для whisper (он сам нормализует).
|
||||
* Коэффициент усиления микрофона: ×3 (без клиппа — ARGT78 пишет пик 1–9%, ×3 даёт
|
||||
* 3–27%). Whisper распознал и сырой (пик 1521), и ×3, и ×10 — усиление нужно
|
||||
* только чтобы телефонный whisper.cpp не терял тихие участки.
|
||||
*/
|
||||
internal const val MIC_GAIN = 5f
|
||||
internal const val MIC_GAIN = 3f
|
||||
|
||||
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
||||
internal const val CHUNK_BYTES = 3_200
|
||||
@@ -218,7 +172,7 @@ internal const val CHUNK_BYTES = 3_200
|
||||
internal const val MIC_SAMPLE_RATE = 16_000
|
||||
|
||||
/**
|
||||
* Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper).
|
||||
* Цифровое усиление фрейма s16le mono с насыщением (для whisper).
|
||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||
*/
|
||||
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
|
||||
@@ -230,7 +184,7 @@ internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray
|
||||
return out
|
||||
}
|
||||
|
||||
/** Склейка речевых фреймов (ShortArray) в один s16le ByteArray. */
|
||||
/** Склейка фреймов (ShortArray) в один s16le ByteArray. */
|
||||
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
||||
var total = 0
|
||||
for (f in frames) total += f.size
|
||||
@@ -246,8 +200,8 @@ internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
||||
}
|
||||
|
||||
/**
|
||||
* Даунсэмпл 48→16 кГц для whisper: склейка 48 кГц-фреймов, FIR (скользящее
|
||||
* среднее 3) против алиасинга, берём каждый 3-й сэмпл. Длина = 1/3 исходной.
|
||||
* Даунсэмпл 48→16 кГц (сохранён для тестов; в проде не используется — 16 кГц напрямую):
|
||||
* склейка 48 кГц-фреймов, FIR (скользящее среднее 3) против алиасинга, каждый 3-й сэмпл.
|
||||
*/
|
||||
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
|
||||
var total = 0
|
||||
|
||||
Reference in New Issue
Block a user