stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# TASK-mic-gain.md — усиление микрофона очков (фикс «пустой фразы»)
|
||||
|
||||
## Симптом (пользователь, 24.08 вечер)
|
||||
|
||||
Очки: TripleClick → «слушаю» → пользователь говорит → Click («отправить фразу») → очки показывают «думаю...» и СРАЗУ возвращаются в «слушаю». Ответа нет. Впечатление: «процесс говорения сломан».
|
||||
|
||||
## Диагноз (подтверждён дампами и логами 24.08)
|
||||
|
||||
Цепь: очки `SttMicStream` (AudioRecord MIC 16 кГц s16 mono) → BT SPP → телефон `SttStreamer` (Silero VAD → sherpa whisper-small) → `StopStt(cancel=false)` → `GlassesServer` → `full.isBlank()` → `SttCancel(REASON_USER)` → очки возвращаются в «слушаю».
|
||||
|
||||
Факты:
|
||||
1. **Жесты/протокол/цикл здоровы** — симуляция `DEBUG_GESTURE` (triple_click → click) воспроизводит симптом, очки логируют `Click → отправить фразу`, `stt отменено — возврат в «слушаю» (SttCancel)`. Это штатное поведение для ПУСТОЙ фразы.
|
||||
2. **Микрофон RayNeo X2 пишет сигнал пиком 1–9% шкалы** (пики 181–2966 из 32768; RMS 9–184). Дампы `stt_dump_1930*.wav` (19:30, голос пользователя): после нормализации faster-whisper-base распознаёт «тихо тихо», «спокойная музыка» — РЕЧЬ есть, но амплитуда ничтожна.
|
||||
3. **Транспорт очки→телефон чист** (analyze.py: порядок окон 98%, лаг 0, объём совпадает) — телефон получает тот же тихий PCM.
|
||||
4. **STT-пайплайн здоров** — на эмуляторе (WAV нормального уровня) распознавание работает; на амплитудах ~0.005–0.09 Silero VAD/whisper-small не видят речи → `full` пуст → пустой клик → возврат в «слушаю».
|
||||
5. Вчерашний фикс кадров/байтов (`c940a7e`+`fc84ba1`+`40edd88`) правильный и НЕ трогается — он убрал «через один», но уровень микрофона не поднял.
|
||||
|
||||
**Корень: на пути аудио очки→телефон нет усиления. Микрофон тихий, VAD/Whisper не видят речь.**
|
||||
|
||||
## Задача
|
||||
|
||||
Добавить **фиксированное цифровое усиление** в `app-glasses` `SttMicStream.kt`, в живой ветке микрофона: чанк после `micChunkBytes(buf, frames)` усилить в `MIC_GAIN` раз (с clamp), и уже усиленный чанк отправлять `onChunk(...)` и писать в дамп.
|
||||
|
||||
- Точка правки: `app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt`
|
||||
- Тесты: `app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt` (существующий файл, добавить тесты)
|
||||
- НЕ трогать: `HostToGlasses.kt`, `GlassesServer.kt`, `HostConnection.kt`, `MainActivity.kt` (охраняемые 4 файла фикса `7557d72`), телефонную сторону (`app-phone`), протокол (`lib-core`), `WavWriter.kt`, существующую функцию `micChunkBytes` и её тесты.
|
||||
- `SttMicStream.kt` писать можно (не в охране).
|
||||
|
||||
## Реализация
|
||||
|
||||
В `SttMicStream.kt`, рядом с `micChunkBytes`:
|
||||
|
||||
```kotlin
|
||||
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
|
||||
internal const val MIC_GAIN = 10f
|
||||
|
||||
/**
|
||||
* Цифровое усиление чанка s16le mono с насыщением.
|
||||
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
|
||||
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
|
||||
*/
|
||||
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray
|
||||
```
|
||||
|
||||
Разбор s16le: младший байт + (старший << 8), знак как в `s16leToFloat` (app-phone) — `if (s >= 0x8000) s -= 0x10000`.
|
||||
|
||||
В цикле записи (живая ветка, после фикса `40edd88`):
|
||||
|
||||
```kotlin
|
||||
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2)
|
||||
if (frames > 0) {
|
||||
val raw = micChunkBytes(buf, frames)
|
||||
val chunk = gainChunk(raw) // ← усиление здесь
|
||||
if (dumpWriter != null) runCatching { dumpWriter.write(chunk) }.onFailure { ... }
|
||||
onChunk(chunk)
|
||||
}
|
||||
```
|
||||
|
||||
**ВАЖНО: дамп (`WavWriter`) пишет УСИЛЕННЫЙ чанк** (то же, что уходит на телефон) — чтобы дампы очков отражали то, что реально получает STT. (Решение принято: диагностика по дампам очков = тому, что видит телефон; сырой микрофон больше не пишем.)
|
||||
|
||||
Обоснование gain = 10: лучший утренний пик речи 2966 (9.05%) → 29660 (90.5%); тихая фраза 483 (1.5%) → 4830 (14.7%) — уже видно VAD. При крике возможен клиппинг — приемлемо (лучше клиппинг, чем пустота). Если на железе VAD захочет ложно срабатывать — константу подстроим.
|
||||
|
||||
## Тесты (добавить в MicChunkTest.kt)
|
||||
|
||||
1. `gainChunk` тихого чанка (сэмплы ±1000): пик результата ≥ 9000, размер = 3200 байт.
|
||||
2. `gainChunk` громкого чанка (сэмплы ±30000): ни один сэмпл не выходит за ±32767 (clamp работает).
|
||||
3. `gainChunk` нулевого чанка: байты без изменений.
|
||||
4. `gainChunk` с gain = 1f: байты побайтово идентичны входу (для gain=1 не аллоцировать заново — вернуть `bytes`).
|
||||
5. Сохранение порядка: `gainChunk` не перемешивает сэмплы (знак/порядок сохраняется — проверка первых 2 сэмплов).
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные (было 95, станет 95 + новые).
|
||||
2. Полная сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`.
|
||||
3. Коммит отдельный: `stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза)`. **push НЕ делать** — приёмка на железе.
|
||||
|
||||
## Приёмка на железе (делает Hermes + пользователь, НЕ агент)
|
||||
|
||||
- Установить оба APK (очки + телефон) с этого коммита, force-stop, `am start`.
|
||||
- Маркеры дампов `stt_dump.marker` уже лежат на обоих устройствах — дампы пишутся.
|
||||
- Пользователь: TripleClick → говорит «поставь спокойную музыку» → Click.
|
||||
- Hermes снимает: дампы очков (пик речи должен стать ~50–90%), `stt_dump_phone.raw` (телефон получил усиленный PCM), logcat: `SttDone` с непустым текстом → LLM-запрос → ответ на очки.
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -29,7 +30,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) {
|
||||
@@ -62,7 +63,8 @@ class SttMicStream(
|
||||
while (isActive && shouldRun()) {
|
||||
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ
|
||||
if (frames > 0) {
|
||||
val chunk = micChunkBytes(buf, frames)
|
||||
val raw = micChunkBytes(buf, frames)
|
||||
val chunk = gainChunk(raw) // усиление: в дамп и на телефон — один и тот же чанк
|
||||
if (dumpWriter != null) {
|
||||
runCatching { dumpWriter.write(chunk) }
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
@@ -91,7 +93,7 @@ class SttMicStream(
|
||||
|
||||
/**
|
||||
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||
* открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда
|
||||
* открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда
|
||||
* до отправки в onChunk (эмуляторную ветку не трогаем).
|
||||
*/
|
||||
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||
@@ -152,6 +154,33 @@ 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).
|
||||
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
|
||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||
*/
|
||||
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray {
|
||||
if (gain == 1f) return bytes
|
||||
val out = ByteArray(bytes.size)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i + 1 < bytes.size) {
|
||||
val lo = bytes[i].toInt() and 0xff
|
||||
val hi = bytes[i + 1].toInt() and 0xff
|
||||
i += 2
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
val v = (s * gain).roundToInt().coerceIn(-32_768, 32_767)
|
||||
out[j++] = (v and 0xff).toByte()
|
||||
out[j++] = ((v shr 8) and 0xff).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
|
||||
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
|
||||
val out = ByteArray(samples.size * 2)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotSame
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
|
||||
@@ -44,5 +46,76 @@ class MicChunkTest {
|
||||
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)")
|
||||
}
|
||||
|
||||
/*
|
||||
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
|
||||
* gainChunk ×10 поднимает речь до уровня, который видят Silero VAD/Whisper.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun quietChunk_gained10_peakReachesAtLeast9000_sizeKept() {
|
||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
|
||||
val out = gainChunk(chunk)
|
||||
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
||||
val peak = s16Samples(out).maxOf { abs(it) }
|
||||
assertTrue(peak >= 9_000, "тихий пик 1000 × 10 должен дать ≥ 9000, а не $peak")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loudChunk_gained10_clampedInsideS16Range() {
|
||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
|
||||
val out = gainChunk(chunk)
|
||||
val s = s16Samples(out)
|
||||
assertEquals(32_767, s.max(), "30000 × 10 = 300000 → насыщение до 32767")
|
||||
assertEquals(-32_768, s.min(), "-30000 × 10 = -300000 → насыщение до -32768")
|
||||
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroChunk_gained10_bytesUnchanged() {
|
||||
val chunk = ByteArray(CHUNK_BYTES)
|
||||
val out = gainChunk(chunk)
|
||||
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
||||
assertTrue(out.all { it == 0.toByte() }, "нули остаются нулями после усиления")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gainEqualsOne_returnsSameInstance_noReallocation() {
|
||||
val chunk = s16leChunk(1_600) { j -> (j % 3_001) - 1_500 }
|
||||
assertSame(chunk, gainChunk(chunk, 1f), "gain = 1: не аллоцируем заново — возвратим сами bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
||||
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
||||
val s = s16Samples(gainChunk(chunk))
|
||||
assertEquals(-1_230, s[0], "первый сэмпл: знак сохранён и усилен ×10")
|
||||
assertEquals(4_560, s[1], "второй сэмпл: порядок сохранён и усилен ×10")
|
||||
}
|
||||
|
||||
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
||||
private fun s16leChunk(n: Int, make: (Int) -> Int): ByteArray {
|
||||
val out = ByteArray(n * 2)
|
||||
for (j in 0 until n) {
|
||||
val v = make(j)
|
||||
out[j * 2] = (v and 0xff).toByte()
|
||||
out[j * 2 + 1] = ((v shr 8) and 0xff).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** s16le ByteArray → IntArray сэмплов (разбор как в s16leToFloat app-phone). */
|
||||
private fun s16Samples(bytes: ByteArray): IntArray {
|
||||
val n = bytes.size / 2
|
||||
val out = IntArray(n)
|
||||
for (j in 0 until n) {
|
||||
val lo = bytes[j * 2].toInt() and 0xff
|
||||
val hi = bytes[j * 2 + 1].toInt() and 0xff
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
out[j] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun recorderWouldReturn(frames: Int) = frames
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user