stt: полный поток без VAD (резал речь на рваном ARGT78) — whisper распознаёт сырой поток (проверено: 1-10 идеально), усиление ×3, сырой дамп
This commit is contained in:
@@ -62,7 +62,9 @@ dependencies {
|
|||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
|
// 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-движок)
|
// WS-клиент очков (OkHttp — Android-движок)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|||||||
@@ -3,15 +3,6 @@ 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.FileInputStream
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -19,18 +10,22 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.glasses.log
|
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 автора): микрофон читается
|
* История: WebRTC VAD резал речь на огрызки — микрофон ARGT78 пишет рвано
|
||||||
* фреймами по 20 мс (320 сэмплов @ 16 кГц), речевые фреймы накапливаются в
|
* (пачки ~50 мс звука / ~50 мс тишины), VAD принимал внутренние провалы за паузы
|
||||||
* сегмент (с пре-роллом тишины 200 мс), а наружу уходит только закрытый
|
* и ловил лишь ~15% речи. Локальный тест whisper на сыром дампе («1,2,3,4,5
|
||||||
* сегмент — межфразовая тишина (у микрофона ARGT78 её ~75%!) не шлётся.
|
* 6,7,8,9,10») распознал полный файл идеально — whisper сам справляется с
|
||||||
* Телефон получает склеенную речь — whisper видит непрерывный звук.
|
* рваностью и тихим уровнем (пик ~1500). Поэтому: весь PCM уходит на телефон
|
||||||
*
|
* чанками по 100 мс, усиление ×3 (без клиппа), телефон копит полный буфер
|
||||||
* Список кусков/стоп — по [shouldRun]: как только очки перестали слушать,
|
* сессии и шлёт его в whisper по клику.
|
||||||
* незакрытый сегмент отправляется (полный текст по клику).
|
|
||||||
*/
|
*/
|
||||||
class SttMicStream(
|
class SttMicStream(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
@@ -38,7 +33,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) {
|
||||||
@@ -46,7 +41,7 @@ class SttMicStream(
|
|||||||
val minBuffer = AudioRecord.getMinBufferSize(
|
val minBuffer = AudioRecord.getMinBufferSize(
|
||||||
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
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 {
|
val recorder = runCatching {
|
||||||
AudioRecord(
|
AudioRecord(
|
||||||
MediaRecorder.AudioSource.MIC,
|
MediaRecorder.AudioSource.MIC,
|
||||||
@@ -70,56 +65,25 @@ class SttMicStream(
|
|||||||
job = scope.launch(Dispatchers.IO) {
|
job = scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
recorder.startRecording()
|
recorder.startRecording()
|
||||||
log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)")
|
log("stt", "микрофон запущен (16 кГц, полный поток без VAD, усиление ×$MIC_GAIN)")
|
||||||
val vad = VadWebRTC(
|
val frame = ShortArray(FRAME_SIZE) // 320 сэмплов = 20 мс
|
||||||
SampleRate.SAMPLE_RATE_16K,
|
val chunk = ArrayList<ShortArray>(MIC_CHUNK_FRAMES)
|
||||||
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 n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры
|
val n = recorder.read(frame, 0, FRAME_SIZE) // кадры
|
||||||
if (n <= 0) continue
|
if (n <= 0) continue
|
||||||
val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n)
|
val data = if (n == FRAME_SIZE) frame else frame.copyOf(n)
|
||||||
// СЫРОЙ дамп: весь поток микрофона (до усиления/VAD) — что реально слышат очки.
|
// СЫРОЙ дамп: весь поток микрофона (до усиления) — что реально слышат очки.
|
||||||
if (dumpWriter != null) {
|
if (dumpWriter != null) {
|
||||||
runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) }
|
runCatching { dumpWriter.write(shortsToPcm16(listOf(data))) }
|
||||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
}
|
}
|
||||||
// VAD на СЫРОМ фрейме: WebRTC VAD рассчитан на нормальный уровень
|
chunk.add(gainShort(data, MIC_GAIN))
|
||||||
// (-21 dBFS у ARGT78 — ок), усиление ×10 клиппует и ломает детекцию.
|
if (chunk.size >= MIC_CHUNK_FRAMES) {
|
||||||
// Усиленный фрейм — только в сегмент (для whisper).
|
onChunk(shortsToPcm16(chunk))
|
||||||
val boosted = gainShort(data)
|
chunk.clear()
|
||||||
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 мс
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter)
|
if (chunk.isNotEmpty()) onChunk(shortsToPcm16(chunk)) // остаток < 100 мс
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
log("stt", "микрофон: ${t.message}")
|
log("stt", "микрофон: ${t.message}")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,17 +103,9 @@ class SttMicStream(
|
|||||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
.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,
|
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||||
* открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда
|
* открыть stt_dump_HHmmss.wav — сырой поток микрофона допишется туда
|
||||||
* (эмуляторную ветку не трогаем).
|
* (эмуляторную ветку не трогаем).
|
||||||
*/
|
*/
|
||||||
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||||
@@ -196,20 +152,18 @@ class SttMicStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
|
/** Фрейм чтения микрофона: 20 мс при 16 кГц = 320 сэмплов (48 кГц на ARGT78 не отдаёт звук). */
|
||||||
internal const val VAD_FRAME_SIZE = 320
|
internal const val FRAME_SIZE = 320
|
||||||
|
|
||||||
/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */
|
/** Чанк на телефон: 100 мс = 5 фреймов по 20 мс. */
|
||||||
internal const val PRE_ROLL_FRAMES = 10
|
internal const val MIC_CHUNK_FRAMES = 5
|
||||||
|
|
||||||
/** Тишина 600 мс (30 фреймов) считается концом реплики. */
|
|
||||||
internal const val SILENCE_FRAMES_TIMEOUT = 30
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Коэффициент усиления микрофона. ×10 клипповал (ARGT78 пишет пик 1–9% → ×10 даёт
|
* Коэффициент усиления микрофона: ×3 (без клиппа — ARGT78 пишет пик 1–9%, ×3 даёт
|
||||||
* 90%+ и 32767 на громких); ×5 — безопасно для whisper (он сам нормализует).
|
* 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 байт. */
|
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
||||||
internal const val CHUNK_BYTES = 3_200
|
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
|
internal const val MIC_SAMPLE_RATE = 16_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper).
|
* Цифровое усиление фрейма s16le mono с насыщением (для whisper).
|
||||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||||
*/
|
*/
|
||||||
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Склейка речевых фреймов (ShortArray) в один s16le ByteArray. */
|
/** Склейка фреймов (ShortArray) в один s16le ByteArray. */
|
||||||
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
||||||
var total = 0
|
var total = 0
|
||||||
for (f in frames) total += f.size
|
for (f in frames) total += f.size
|
||||||
@@ -246,8 +200,8 @@ internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Даунсэмпл 48→16 кГц для whisper: склейка 48 кГц-фреймов, FIR (скользящее
|
* Даунсэмпл 48→16 кГц (сохранён для тестов; в проде не используется — 16 кГц напрямую):
|
||||||
* среднее 3) против алиасинга, берём каждый 3-й сэмпл. Длина = 1/3 исходной.
|
* склейка 48 кГц-фреймов, FIR (скользящее среднее 3) против алиасинга, каждый 3-й сэмпл.
|
||||||
*/
|
*/
|
||||||
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
|
internal fun downsample48To16(frames: List<ShortArray>): List<ShortArray> {
|
||||||
var total = 0
|
var total = 0
|
||||||
|
|||||||
@@ -52,26 +52,26 @@ class MicChunkTest {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Test
|
@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 chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
|
||||||
val out = gainChunk(chunk)
|
val out = gainChunk(chunk)
|
||||||
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
||||||
val peak = s16Samples(out).maxOf { abs(it) }
|
val peak = s16Samples(out).maxOf { abs(it) }
|
||||||
assertTrue(peak >= 4_500, "тихий пик 1000 × 5 должен дать ≥ 4500, а не $peak")
|
assertTrue(peak >= 2_700, "тихий пик 1000 × 3 должен дать ≥ 2700, а не $peak")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
|
||||||
val out = gainChunk(chunk)
|
val out = gainChunk(chunk)
|
||||||
val s = s16Samples(out)
|
val s = s16Samples(out)
|
||||||
assertEquals(32_767, s.max(), "30000 × 5 = 150000 → насыщение до 32767")
|
assertEquals(32_767, s.max(), "30000 × 3 = 90000 → насыщение до 32767")
|
||||||
assertEquals(-32_768, s.min(), "-30000 × 5 = -150000 → насыщение до -32768")
|
assertEquals(-32_768, s.min(), "-30000 × 3 = -90000 → насыщение до -32768")
|
||||||
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun zeroChunk_gained5_bytesUnchanged() {
|
fun zeroChunk_gained3_bytesUnchanged() {
|
||||||
val chunk = ByteArray(CHUNK_BYTES)
|
val chunk = ByteArray(CHUNK_BYTES)
|
||||||
val out = gainChunk(chunk)
|
val out = gainChunk(chunk)
|
||||||
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
||||||
@@ -88,8 +88,8 @@ class MicChunkTest {
|
|||||||
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
||||||
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
||||||
val s = s16Samples(gainChunk(chunk))
|
val s = s16Samples(gainChunk(chunk))
|
||||||
assertEquals(-615, s[0], "первый сэмпл: знак сохранён и усилен ×5")
|
assertEquals(-369, s[0], "первый сэмпл: знак сохранён и усилен ×3")
|
||||||
assertEquals(2_280, s[1], "второй сэмпл: порядок сохранён и усилен ×5")
|
assertEquals(1_368, s[1], "второй сэмпл: порядок сохранён и усилен ×3")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
||||||
|
|||||||
Reference in New Issue
Block a user