stt(glasses): VAD на очках (WebRTC, схема pair-view) — речевые сегменты с пре-роллом, межфразовая тишина не шлётся
This commit is contained in:
@@ -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} фреймов с пре-роллом)")
|
||||
}
|
||||
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) {
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user