stt: debug-дампы аудио-пути (очки: сырой микрофон в WAV; телефон: принятые PCM) по старту от stt_dump.marker

This commit is contained in:
2026-08-24 05:58:59 +03:00
parent c1c169036a
commit 22eb83fd17
5 changed files with 203 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
# TASK: debug-дампы аудио-пути очки→телефон (PCM-записи для диагностики STT-качества)
## Подозреваемые (по фактам разведки)
(a) убогий микрофон очков (AudioRecord MIC 16 кГц s16 mono, buf 6400, чтение 3200 Б = 100 мс);
(b) транспорт: PCM идёт как **base64 в JSON-строке** (`SttAudio.data` → kotlinx-serialization
default ByteArray-сериализатор = base64) по NEWLINE-каналу SPP/WiFi (readLines/readLine);
100-мс-чанк 3200 Б s16 → ~4300 Б base64+JSON. Потери/задержки/перепутанный порядок кусков
по шкатулке — верный кандидат «убожества»;
(c) VAD/Whisper на телефоне (настройки: VAD 0.5/0.25с/0.25с/20с, Whisper-small int8, CPU, 4
потока — на старом HONOR CPU-распознавание медленное и может «не успевать»).
## Цель
Запись сырого PCM в двух точках (очки: ДО отправки; телефон: ПОСЛЕ приёма и декода) +
сравнение: объёмы/RMS/порядок. Дальше (утром, агент+user): снять с устройств, снять
офлайн-ASR-картину, решить (a)/(b)/(c).
## Оковы
- Пути только относительные от корня репо /root/WORK/view-mate. Никаких /tmp.
- НЕ ТРОГАТЬ: HostToGlasses.kt, GlassesServer.kt, HostConnection.kt, MainActivity.kt (оба модуля).
- Правим: SttMicStream.kt (очки), SttStreamer.kt (телефон), PhoneApp.kt (телефон, НЕ охраняется), новый WavWriter.kt (очки).
## Изменения
### 1. app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt (новый)
Минимальный WAV-райтер (PCM s16le, 16000 Hz, mono):
- `class WavWriter(path: File, sampleRate: Int = 16000, channels: Int = 1)`
- `fun write(pcm: ByteArray)` — appends (header пишется один раз при создании файла).
- `close()`. Стандартный RIFF/PCM header 44 байта.
### 2. app-glasses: SttMicStream.kt
- В `start(filesDir, shouldRun)`: перед запуском потока проверить маркер
`File(filesDir, "stt_dump.marker")`. Если есть:
- создать `File(filesDir, "stt_dump_" + java.time формат HHmmss + ".wav")` через WavWriter;
- ВСЕ чанки, уходящие в onChunk (только ветка ЖИВОГО микрофона, эмуляторную не трогаем),
дописывать в WavWriter;
- log("stt", "dump микрофона: <path>").
- При `stop()` — закрыть WavWriter (runCatching).
- Все операции с dump — runCatching, сбои — только log, поток звука не прерывать.
### 3. app-phone: SttStreamer.kt
- Конструктор: добавить опциональный параметр `dumpPcmTo: File? = null`
(хвост конструктора, чтобы не сломать существующие вызовы).
- В `accept(pcm)`: если `dumpPcmTo != null` — дописать `pcm` в файл
(DataOutputStream/FileOutputStream append, raw s16le, header НЕ нужен — файл помечен как
16кГц s16 mono в логе). Все runCatching.
- В `close()`: закрыть поток дампа.
- Лог: "stt" логгер — "PCM-дампа: <path>".
### 4. app-phone: PhoneApp.kt (ensureStt(), ~строка 368)
- При создании SttStreamer передать `dumpPcmTo`:
```kotlin
val dumpMarker = File(filesDir, "stt_dump.marker")
val dumpPcm = if (dumpMarker.exists()) File(filesDir, "stt_phone_dump.raw") else null
```
(посмотреть по факту, какой context/filesDir в области видимости).
- Лог: "stt" — "PCM-дампа (телефон): <path или off>".
### 5. Прогон
- `./gradlew :app-glasses:testDebugUnitTest :app-phone:testDebugUnitTest :lib-core:jvmTest` — зелёные.
- `./gradlew :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL.
### 6. Коммит
- `git add -A && git commit -m "stt: debug-дампы аудио-пути (очки: сырой микрофон в WAV; телефон: принятые PCM) — старт по stt_dump.marker"`
- PUSH не делать.
## Для утренней диагностики (агент + user)
- На очках: `touch` маркера (adb shell run-as или в самом каталоге /sdcard/Android/data/.../files/stt_dump.marker — агент уточнит path).
- На телефоне: аналогично.
- Пользователь произносит фразы в очки → очки делают dump; после сессии — adb pull обоих файлов.
- Сравнение: (1) объём/продолжительность (должны совпадать с точностью на куски),
(2) RMS/клиппинг в очках-файле (диагноз (a)), (3) побайтовое/почанковое сравнение
очков↔телефон (диагноз (b)), (4) запустить обе записи через серверный ASR (88.125:8006) —
«убожество» воспроизводимо в оффлайне?
@@ -5,6 +5,8 @@ import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.File
import java.io.FileInputStream
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,6 +29,9 @@ class SttMicStream(
) {
private var job: Job? = null
/** Debug-дампер сырого микрофона (открывается по маркеру stt_dump.marker). */
private var dump: WavWriter? = null
fun start(filesDir: File, shouldRun: () -> Boolean) {
job?.cancel()
val recorder = runCatching {
@@ -42,10 +47,13 @@ class SttMicStream(
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder?.release()
log("stt", "микрофон недоступен — отладка: PCM из файла")
dump = null
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
return
}
dump = openLiveMicDump(filesDir)
val dumpWriter = dump
job = scope.launch(Dispatchers.IO) {
try {
recorder.startRecording()
@@ -55,6 +63,10 @@ class SttMicStream(
val n = recorder.read(buf, 0, buf.size)
if (n > 0) {
val chunk = if (n == buf.size) buf else buf.copyOf(n)
if (dumpWriter != null) {
runCatching { dumpWriter.write(chunk) }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
}
onChunk(chunk)
}
}
@@ -63,6 +75,8 @@ class SttMicStream(
} finally {
runCatching { recorder.stop() }
recorder.release()
runCatching { dumpWriter?.close() }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
log("stt", "микрофон остановлен")
}
}
@@ -71,6 +85,24 @@ class SttMicStream(
fun stop() {
job?.cancel()
job = null
runCatching { dump?.close() }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
}
/**
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
* открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда
* до отправки в onChunk (эмуляторную ветку не трогаем).
*/
private fun openLiveMicDump(filesDir: File): WavWriter? {
if (!File(filesDir, "stt_dump.marker").exists()) return null
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"))
val path = File(filesDir, "stt_dump_$stamp.wav")
return runCatching {
WavWriter(path).also { log("stt", "dump микрофона: $path") }
}
.onFailure { log("stt", "dump микрофона: ${it.message}") }
.getOrNull()
}
/**
@@ -0,0 +1,68 @@
package pw.binom.viewmate.glasses.stt
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Минимальный WAV-райтер (PCM s16le): 44-байтовый RIFF-заголовок пишется один раз
* при создании файла, [write] дописывает чанки, на close в заголовок дописываются
* реальные размеры (best-effort — при сбое ридер читает файл до EOF, размеры
* 0xFFFFFFFF — конвенция стриминговых WAV).
*
* Не потокобезопасен — вызывать из одного потока (поток микрофона).
*/
class WavWriter(
private val path: File,
private val sampleRate: Int = 16_000,
private val channels: Int = 1,
) {
private val out: FileOutputStream
private var dataBytes = 0L
private var closed = false
init {
out = FileOutputStream(path)
out.write(headerBytes())
}
/** Дописать чанк s16le PCM (16 кГц, mono). */
fun write(pcm: ByteArray) {
out.write(pcm)
dataBytes += pcm.size
}
/** Закрыть файл и прописать реальные размеры в заголовок (идемпотентно). */
fun close() {
if (closed) return
closed = true
out.close()
runCatching {
RandomAccessFile(path, "rw").use { raf ->
raf.seek(4)
raf.writeInt((36 + dataBytes).toInt()) // размер RIFF
raf.seek(40)
raf.writeInt(dataBytes.toInt()) // размер data
}
}
}
private fun headerBytes(): ByteArray =
ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
put("RIFF".toByteArray())
putInt(-1) // placeholder
put("WAVE".toByteArray())
put("fmt ".toByteArray())
putInt(16)
putShort(1.toShort()) // PCM
putShort(channels.toShort())
putInt(sampleRate)
putInt(sampleRate * channels * 2)
putShort((channels * 2).toShort())
putShort(16.toShort())
put("data".toByteArray())
putInt(-1) // placeholder
}.array()
}
@@ -366,6 +366,11 @@ class PhoneApp : Application() {
tokensPath = tokens.absolutePath,
numThreads = 4,
)
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw")
} else {
null
}
val s = SttStreamer(
stt = whisper,
vad = SherpaStreamingVad(vadModel.absolutePath),
@@ -378,8 +383,10 @@ class PhoneApp : Application() {
log("stt", "тишина 30 с — автo-отмена")
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
},
dumpPcm = dumpPcm,
)
sttStreamer = s
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
return s
}
@@ -1,10 +1,13 @@
package pw.binom.viewmate.phone.stt
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.concurrent.withLock
import pw.binom.viewmate.phone.log
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
@@ -37,6 +40,7 @@ class SttStreamer(
private val vad: StreamingVad,
private val onPhrase: (phrase: String, full: String) -> Unit,
private val onSilence30s: () -> Unit,
private val dumpPcm: File? = null,
) {
companion object {
private const val SILENCE_TIMEOUT_MS = 30_000L
@@ -60,6 +64,18 @@ class SttStreamer(
@Volatile
private var deadlineMs = 0L
/**
* Debug-дампа принятых PCM: сырые s16le (16 кГц, mono), без WAV-заголовка —
* формат помечен в логе. Сбой открытия не ломает распознавание.
*/
private val dumpOut: FileOutputStream? = dumpPcm?.let { file ->
runCatching {
FileOutputStream(file, true).also {
log("stt", "PCM-дампа: ${file.absolutePath} (16 кГц, s16, mono)")
}
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
}
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
private var pending = FloatArray(0)
@@ -109,6 +125,11 @@ class SttStreamer(
}
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
}
val out = dumpOut
if (out != null) {
runCatching { out.write(pcm) }
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
}
}
/**
@@ -141,6 +162,8 @@ class SttStreamer(
deadlineMs = 0
vad.close()
}
runCatching { dumpOut?.close() }
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
executor.shutdownNow()
watchdog.interrupt()
}