stt(glasses): VAD на очках (WebRTC, схема pair-view) — речевые сегменты с пре-роллом, межфразовая тишина не шлётся
This commit is contained in:
@@ -61,6 +61,9 @@ dependencies {
|
||||
implementation(project(":lib-core"))
|
||||
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-движок)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
|
||||
@@ -3,6 +3,10 @@ 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
|
||||
@@ -18,11 +22,15 @@ import pw.binom.viewmate.glasses.log
|
||||
|
||||
/**
|
||||
* Поток распознаваемого аудио очков: куски 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(
|
||||
private val scope: CoroutineScope,
|
||||
@@ -30,7 +38,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) {
|
||||
@@ -38,10 +46,10 @@ class SttMicStream(
|
||||
val recorder = runCatching {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
MIC_SAMPLE_RATE,
|
||||
16_000,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
MIC_BUFFER_BYTES,
|
||||
6_400,
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -58,24 +66,48 @@ class SttMicStream(
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
recorder.startRecording()
|
||||
log("stt", "микрофон запущен ($MIC_SAMPLE_RATE Гц, s16, mono)")
|
||||
val buf = ByteArray(MIC_CHUNK_FRAMES * 2) // 100 мс при 48 кГц
|
||||
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
|
||||
while (isActive && shouldRun()) {
|
||||
val frames = recorder.read(buf, 0, MIC_CHUNK_FRAMES) // inFrameCount — КАДРЫ
|
||||
if (frames > 0) {
|
||||
val raw = micChunkBytes(buf, frames)
|
||||
// 48→16 кГц: каждый 3-й сэмпл (whisper/VAD ждут 16 кГц).
|
||||
// Микрофон ARGT78 на 16 кГц писал рвано (ровные провалы 100 мс) —
|
||||
// на 48 кГц (нативная частота драйвера) поток ожидается ровным.
|
||||
val down = downsample48To16(raw)
|
||||
val chunk = gainChunk(down) // усиление: в дамп и на телефон — один и тот же чанк
|
||||
if (dumpWriter != null) {
|
||||
runCatching { dumpWriter.write(chunk) }
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
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)) {
|
||||
if (!speeching) {
|
||||
speeching = true
|
||||
silenceFrames = 0
|
||||
segment.clear()
|
||||
while (preRoll.isNotEmpty()) segment.add(preRoll.removeFirst())
|
||||
log("stt", "🎤 речь началась (${segment.size} фреймов с пре-роллом)")
|
||||
}
|
||||
onChunk(chunk)
|
||||
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 мс
|
||||
}
|
||||
}
|
||||
}
|
||||
if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter)
|
||||
} catch (t: Throwable) {
|
||||
log("stt", "микрофон: ${t.message}")
|
||||
} finally {
|
||||
@@ -95,10 +127,22 @@ class SttMicStream(
|
||||
.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,
|
||||
* открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда
|
||||
* до отправки в onChunk (эмуляторную ветку не трогаем).
|
||||
* открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда
|
||||
* (эмуляторную ветку не трогаем).
|
||||
*/
|
||||
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||
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 байт. */
|
||||
internal const val CHUNK_BYTES = 3_200
|
||||
|
||||
/** Микрофон очков пишем на нативной частоте драйвера (48 кГц), дальше даунсэмплим в 16 кГц. */
|
||||
internal const val MIC_SAMPLE_RATE = 48_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
|
||||
/** Микрофон очков: родная 16 кГц (WebRTC VAD рекомендует 16 кГц, whisper тоже). */
|
||||
internal const val MIC_SAMPLE_RATE = 16_000
|
||||
|
||||
/**
|
||||
* 48 кГц → 16 кГц: каждый 3-й сэмпл s16le mono. frames — число кадров,
|
||||
* не кратных 3 — хвост отбрасывается. На выходе — байты s16le 16 кГц.
|
||||
* Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper).
|
||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||
*/
|
||||
internal fun downsample48To16(buf: ByteArray, frames: Int = buf.size / 2): ByteArray {
|
||||
val usable = frames - (frames % 3)
|
||||
val out = ByteArray(usable / 3 * 2)
|
||||
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
|
||||
if (gain == 1f) return samples
|
||||
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 k = 0
|
||||
while (k < usable * 2) {
|
||||
out[i++] = buf[k]
|
||||
out[i++] = buf[k + 1]
|
||||
k += 6
|
||||
for (f in frames) {
|
||||
for (s in f) {
|
||||
out[i++] = (s.toInt() and 0xff).toByte()
|
||||
out[i++] = ((s.toInt() shr 8) and 0xff).toByte()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -184,9 +245,6 @@ internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
|
||||
return if (bytes == buf.size) buf else buf.copyOf(bytes)
|
||||
}
|
||||
|
||||
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
|
||||
internal const val MIC_GAIN = 10f
|
||||
|
||||
/**
|
||||
* Цифровое усиление чанка s16le mono с насыщением.
|
||||
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
|
||||
|
||||
@@ -120,34 +120,50 @@ class MicChunkTest {
|
||||
private fun recorderWouldReturn(frames: Int) = frames
|
||||
|
||||
/*
|
||||
* Даунсемплинг 48→16 кГц (TASK-mic-48k): микрофон ARGT78 на 16 кГц пишет
|
||||
* рвано (ровные провалы тишины с периодом 100 мс) — пишем на 48 кГц
|
||||
* (нативная частота драйвера) и прореживаем каждый 3-й сэмпл.
|
||||
* VAD на очках (схема pair-view): фреймы 20 мс (320 сэмплов @ 16 кГц),
|
||||
* усиление ×10 перед VAD и whisper, склейка речевых сегментов в s16le.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun downsample_4800Frames_gives3200Bytes() {
|
||||
val buf = ByteArray(MIC_CHUNK_FRAMES * 2) // 4800 кадров = 9600 байт
|
||||
val out = downsample48To16(buf, MIC_CHUNK_FRAMES)
|
||||
assertEquals(3_200, out.size, "4800 кадров 48кГц → 1600 сэмплов 16кГц = 3200 байт")
|
||||
fun gainShort_quietSamples_boosted10_saturated() {
|
||||
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
|
||||
val out = gainShort(samples, 10f)
|
||||
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
|
||||
fun downsample_takesEveryThirdSample() {
|
||||
// 6 кадров 48кГц (12 байт): 0,1,2,3,4,5 → 0 и 3 (каждый 3-й)
|
||||
val buf = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
|
||||
val out = downsample48To16(buf, 6)
|
||||
assertEquals(4, out.size, "6 кадров → 2 сэмпла = 4 байта")
|
||||
fun gainShort_gain1_returnsSameArray() {
|
||||
val samples = shortArrayOf(1, 2, 3)
|
||||
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
|
||||
}
|
||||
|
||||
@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(2, out[1].toInt() and 0xff)
|
||||
assertEquals(7, out[2].toInt() and 0xff)
|
||||
assertEquals(8, out[3].toInt() and 0xff)
|
||||
assertEquals(0, out[1].toInt())
|
||||
assertEquals(2, out[2].toInt() and 0xff)
|
||||
assertEquals(0, out[3].toInt())
|
||||
assertEquals(3, out[4].toInt() and 0xff)
|
||||
assertEquals(0, out[5].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downsample_nonMultipleOf3_dropsTail() {
|
||||
val buf = ByteArray(14) // 7 кадров: 6 usable + 1 отброшен
|
||||
val out = downsample48To16(buf, 7)
|
||||
assertEquals(4, out.size, "6 usable кадров → 2 сэмпла")
|
||||
fun shortsToPcm16_negativeSample_twoBytesComplement() {
|
||||
val out = shortsToPcm16(listOf(shortArrayOf(-1)))
|
||||
assertEquals(0xff, out[0].toInt() and 0xff)
|
||||
assertEquals(0xff, out[1].toInt() and 0xff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortsToPcm16_empty_returnsEmpty() {
|
||||
assertEquals(0, shortsToPcm16(emptyList()).size)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user