stt: полный поток без VAD (резал речь на рваном ARGT78) — whisper распознаёт сырой поток (проверено: 1-10 идеально), усиление ×3, сырой дамп

This commit is contained in:
2026-08-25 03:12:04 +03:00
parent d34d15ecef
commit b0baa19e7d
3 changed files with 50 additions and 94 deletions
+3 -1
View File
@@ -62,7 +62,9 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
// implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
// WS-клиент очков (OkHttp — Android-движок)
implementation(libs.ktor.client.okhttp)
@@ -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
@@ -52,26 +52,26 @@ class MicChunkTest {
*/
@Test
fun quietChunk_gained5_peakReachesAtLeast4500_sizeKept() {
fun quietChunk_gained3_peakReachesAtLeast2700_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 >= 4_500, "тихий пик 1000 × 5 должен дать ≥ 4500, а не $peak")
assertTrue(peak >= 2_700, "тихий пик 1000 × 3 должен дать ≥ 2700, а не $peak")
}
@Test
fun loudChunk_gained5_clampedInsideS16Range() {
fun loudChunk_gained3_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 × 5 = 150000 → насыщение до 32767")
assertEquals(-32_768, s.min(), "-30000 × 5 = -150000 → насыщение до -32768")
assertEquals(32_767, s.max(), "30000 × 3 = 90000 → насыщение до 32767")
assertEquals(-32_768, s.min(), "-30000 × 3 = -90000 → насыщение до -32768")
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
}
@Test
fun zeroChunk_gained5_bytesUnchanged() {
fun zeroChunk_gained3_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(-615, s[0], "первый сэмпл: знак сохранён и усилен ×5")
assertEquals(2_280, s[1], "второй сэмпл: порядок сохранён и усилен ×5")
assertEquals(-369, s[0], "первый сэмпл: знак сохранён и усилен ×3")
assertEquals(1_368, s[1], "второй сэмпл: порядок сохранён и усилен ×3")
}
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */