@@ -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,51 +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 на СЫРОМ фрейме: 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} фреймов с пре-роллом) " )
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} " ) }
}
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 {
@@ -134,21 +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 )
if ( dumpWriter != null ) {
runCatching { dumpWriter . write ( pcm ) }
. onFailure { log ( " stt " , " dump микрофона: ${it.message} " ) }
}
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 ? {
@@ -195,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
/** Пре-ролл тишины перед речью : 2 00 мс = 10 фреймов. */
internal const val PRE _ROLL _FRAMES = 10
/** Тишина 600 мс (30 фреймов) считается концом реплики. */
internal const val SILENCE _FRAMES _TIMEOUT = 30
/** Чанк на телефон : 1 00 мс = 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
@@ -217,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 {
@@ -229,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
@@ -245,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