stt(glasses): VAD на очках (WebRTC, схема pair-view) — речевые сегменты с пре-роллом, межфразовая тишина не шлётся

This commit is contained in:
2026-08-25 01:51:57 +03:00
parent d78ed4429e
commit b17b5bf424
3 changed files with 140 additions and 63 deletions
+3
View File
@@ -61,6 +61,9 @@ dependencies {
implementation(project(":lib-core")) implementation(project(":lib-core"))
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
// WS-клиент очков (OkHttp — Android-движок) // WS-клиент очков (OkHttp — Android-движок)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
@@ -3,6 +3,10 @@ package pw.binom.viewmate.glasses.stt
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioRecord import android.media.AudioRecord
import android.media.MediaRecorder 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.File
import java.io.FileInputStream import java.io.FileInputStream
import java.time.LocalTime import java.time.LocalTime
@@ -18,11 +22,15 @@ import pw.binom.viewmate.glasses.log
/** /**
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк. * Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
* filesDir/samples (отладка, см. TASK.md этап 2).
* *
* Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening): * VAD живёт НА ОЧКАХ (WebRTC VAD, схема pair-view автора): микрофон читается
* как только очки перестали слушать, поток обрывается. * фреймами по 20 мс (320 сэмплов @ 16 кГц), речевые фреймы накапливаются в
* сегмент (с пре-роллом тишины 200 мс), а наружу уходит только закрытый
* сегмент — межфразовая тишина (у микрофона ARGT78 её ~75%!) не шлётся.
* Телефон получает склеенную речь — whisper видит непрерывный звук.
*
* Список кусков/стоп — по [shouldRun]: как только очки перестали слушать,
* незакрытый сегмент отправляется (полный текст по клику).
*/ */
class SttMicStream( class SttMicStream(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -30,7 +38,7 @@ class SttMicStream(
) { ) {
private var job: Job? = null private var job: Job? = null
/** Debug-дампер микрофона: пишет УСИЛЕННЫЕ чанки — те же, что уходят на телефон (по маркеру stt_dump.marker). */ /** Debug-дампер микрофона: пишет УСИЛЕННЫЕ сегменты — те же, что уходят на телефон (по маркеру stt_dump.marker). */
private var dump: WavWriter? = null private var dump: WavWriter? = null
fun start(filesDir: File, shouldRun: () -> Boolean) { fun start(filesDir: File, shouldRun: () -> Boolean) {
@@ -38,10 +46,10 @@ class SttMicStream(
val recorder = runCatching { val recorder = runCatching {
AudioRecord( AudioRecord(
MediaRecorder.AudioSource.MIC, MediaRecorder.AudioSource.MIC,
MIC_SAMPLE_RATE, 16_000,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT,
MIC_BUFFER_BYTES, 6_400,
) )
}.getOrNull() }.getOrNull()
@@ -58,24 +66,48 @@ class SttMicStream(
job = scope.launch(Dispatchers.IO) { job = scope.launch(Dispatchers.IO) {
try { try {
recorder.startRecording() recorder.startRecording()
log("stt", "микрофон запущен ($MIC_SAMPLE_RATE Гц, s16, mono)") log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)")
val buf = ByteArray(MIC_CHUNK_FRAMES * 2) // 100 мс при 48 кГц 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
while (isActive && shouldRun()) { while (isActive && shouldRun()) {
val frames = recorder.read(buf, 0, MIC_CHUNK_FRAMES) // inFrameCount — КАДРЫ val n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры
if (frames > 0) { if (n <= 0) continue
val raw = micChunkBytes(buf, frames) val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n)
// 48→16 кГц: каждый 3-й сэмпл (whisper/VAD ждут 16 кГц). val boosted = gainShort(data) // микрофон тихий (пик 1–9%) — ×10 и для VAD, и для whisper
// Микрофон ARGT78 на 16 кГц писал рвано (ровные провалы 100 мс) — if (vad.isSpeech(boosted)) {
// на 48 кГц (нативная частота драйвера) поток ожидается ровным. if (!speeching) {
val down = downsample48To16(raw) speeching = true
val chunk = gainChunk(down) // усиление: в дамп и на телефон — один и тот же чанк silenceFrames = 0
if (dumpWriter != null) { segment.clear()
runCatching { dumpWriter.write(chunk) } while (preRoll.isNotEmpty()) segment.add(preRoll.removeFirst())
.onFailure { log("stt", "dump микрофона: ${it.message}") } 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 мс
} }
onChunk(chunk)
} }
} }
if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter)
} catch (t: Throwable) { } catch (t: Throwable) {
log("stt", "микрофон: ${t.message}") log("stt", "микрофон: ${t.message}")
} finally { } finally {
@@ -95,10 +127,22 @@ class SttMicStream(
.onFailure { log("stt", "dump микрофона: ${it.message}") } .onFailure { log("stt", "dump микрофона: ${it.message}") }
} }
/** Закрытый речевой сегмент: склейка фреймов → s16le → усилен в дамп и на телефон. */
private suspend fun closeSegment(segment: List<ShortArray>, dumpWriter: WavWriter?) {
if (segment.isEmpty()) return
val pcm = shortsToPcm16(segment)
if (dumpWriter != null) {
runCatching { dumpWriter.write(pcm) }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
}
log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 160} мс → на телефон")
onChunk(pcm)
}
/** /**
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker, * Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
* открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда * открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда
* до отправки в onChunk (эмуляторную ветку не трогаем). * (эмуляторную ветку не трогаем).
*/ */
private fun openLiveMicDump(filesDir: File): WavWriter? { private fun openLiveMicDump(filesDir: File): WavWriter? {
if (!File(filesDir, "stt_dump.marker").exists()) return null if (!File(filesDir, "stt_dump.marker").exists()) return null
@@ -144,31 +188,48 @@ class SttMicStream(
} }
} }
/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов. */
internal const val VAD_FRAME_SIZE = 320
/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */
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
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */ /** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
internal const val CHUNK_BYTES = 3_200 internal const val CHUNK_BYTES = 3_200
/** Микрофон очков пишем на нативной частоте драйвера (48 кГц), дальше даунсэмплим в 16 кГц. */ /** Микрофон очков: родная 16 кГц (WebRTC VAD рекомендует 16 кГц, whisper тоже). */
internal const val MIC_SAMPLE_RATE = 48_000 internal const val MIC_SAMPLE_RATE = 16_000
/** 100 мс при 48 кГц: 4800 кадров. */
internal const val MIC_CHUNK_FRAMES = 4_800
/** Минимальный буфер AudioRecord: 2 чанка по 100 мс. */
internal const val MIC_BUFFER_BYTES = MIC_CHUNK_FRAMES * 2 * 2
/** /**
* 48 кГц → 16 кГц: каждый 3-й сэмпл s16le mono. frames — число кадров, * Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper).
* не кратных 3 — хвост отбрасывается. На выходе — байты s16le 16 кГц. * Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
*/ */
internal fun downsample48To16(buf: ByteArray, frames: Int = buf.size / 2): ByteArray { internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
val usable = frames - (frames % 3) if (gain == 1f) return samples
val out = ByteArray(usable / 3 * 2) val out = ShortArray(samples.size)
for (i in samples.indices) {
out[i] = (samples[i] * gain).roundToInt().coerceIn(-32_768, 32_767).toShort()
}
return out
}
/** Склейка речевых фреймов (ShortArray) в один s16le ByteArray. */
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
var total = 0
for (f in frames) total += f.size
val out = ByteArray(total * 2)
var i = 0 var i = 0
var k = 0 for (f in frames) {
while (k < usable * 2) { for (s in f) {
out[i++] = buf[k] out[i++] = (s.toInt() and 0xff).toByte()
out[i++] = buf[k + 1] out[i++] = ((s.toInt() shr 8) and 0xff).toByte()
k += 6 }
} }
return out return out
} }
@@ -184,9 +245,6 @@ internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
return if (bytes == buf.size) buf else buf.copyOf(bytes) return if (bytes == buf.size) buf else buf.copyOf(bytes)
} }
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
internal const val MIC_GAIN = 10f
/** /**
* Цифровое усиление чанка s16le mono с насыщением. * Цифровое усиление чанка s16le mono с насыщением.
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767). * Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
@@ -120,34 +120,50 @@ class MicChunkTest {
private fun recorderWouldReturn(frames: Int) = frames private fun recorderWouldReturn(frames: Int) = frames
/* /*
* Даунсемплинг 48→16 кГц (TASK-mic-48k): микрофон ARGT78 на 16 кГц пишет * VAD на очках (схема pair-view): фреймы 20 мс (320 сэмплов @ 16 кГц),
* рвано (ровные провалы тишины с периодом 100 мс) — пишем на 48 кГц * усиление ×10 перед VAD и whisper, склейка речевых сегментов в s16le.
* (нативная частота драйвера) и прореживаем каждый 3-й сэмпл.
*/ */
@Test @Test
fun downsample_4800Frames_gives3200Bytes() { fun gainShort_quietSamples_boosted10_saturated() {
val buf = ByteArray(MIC_CHUNK_FRAMES * 2) // 4800 кадров = 9600 байт val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
val out = downsample48To16(buf, MIC_CHUNK_FRAMES) val out = gainShort(samples, 10f)
assertEquals(3_200, out.size, "4800 кадров 48кГц → 1600 сэмплов 16кГц = 3200 байт") 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(0, out[4].toInt())
} }
@Test @Test
fun downsample_takesEveryThirdSample() { fun gainShort_gain1_returnsSameArray() {
// 6 кадров 48кГц (12 байт): 0,1,2,3,4,5 → 0 и 3 (каждый 3-й) val samples = shortArrayOf(1, 2, 3)
val buf = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
val out = downsample48To16(buf, 6) }
assertEquals(4, out.size, "6 кадров → 2 сэмпла = 4 байта")
@Test
fun shortsToPcm16_concatenatesLittleEndian() {
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
val out = shortsToPcm16(frames)
assertEquals(6, out.size, "3 сэмпла × 2 байта")
assertEquals(1, out[0].toInt() and 0xff) assertEquals(1, out[0].toInt() and 0xff)
assertEquals(2, out[1].toInt() and 0xff) assertEquals(0, out[1].toInt())
assertEquals(7, out[2].toInt() and 0xff) assertEquals(2, out[2].toInt() and 0xff)
assertEquals(8, out[3].toInt() and 0xff) assertEquals(0, out[3].toInt())
assertEquals(3, out[4].toInt() and 0xff)
assertEquals(0, out[5].toInt())
} }
@Test @Test
fun downsample_nonMultipleOf3_dropsTail() { fun shortsToPcm16_negativeSample_twoBytesComplement() {
val buf = ByteArray(14) // 7 кадров: 6 usable + 1 отброшен val out = shortsToPcm16(listOf(shortArrayOf(-1)))
val out = downsample48To16(buf, 7) assertEquals(0xff, out[0].toInt() and 0xff)
assertEquals(4, out.size, "6 usable кадров → 2 сэмпла") assertEquals(0xff, out[1].toInt() and 0xff)
}
@Test
fun shortsToPcm16_empty_returnsEmpty() {
assertEquals(0, shortsToPcm16(emptyList()).size)
} }
} }