From 22eb83fd171c72afa8f266d43707c25c758edea4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 05:58:59 +0300 Subject: [PATCH] =?UTF-8?q?stt:=20debug-=D0=B4=D0=B0=D0=BC=D0=BF=D1=8B=20?= =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D0=BE-=D0=BF=D1=83=D1=82=D0=B8=20(?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8:=20=D1=81=D1=8B=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=BA=D1=80=D0=BE=D1=84=D0=BE=D0=BD=20=D0=B2=20WAV?= =?UTF-8?q?;=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD:=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=8F=D1=82=D1=8B=D0=B5=20PCM)=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D1=83=20=D0=BE=D1=82=20stt=5Fdum?= =?UTF-8?q?p.marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK-audio-dump.md | 73 +++++++++++++++++++ .../viewmate/glasses/stt/SttMicStream.kt | 32 ++++++++ .../binom/viewmate/glasses/stt/WavWriter.kt | 68 +++++++++++++++++ .../pw/binom/viewmate/phone/PhoneApp.kt | 7 ++ .../binom/viewmate/phone/stt/SttStreamer.kt | 23 ++++++ 5 files changed, 203 insertions(+) create mode 100644 TASK-audio-dump.md create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt diff --git a/TASK-audio-dump.md b/TASK-audio-dump.md new file mode 100644 index 0000000..e91c5ba --- /dev/null +++ b/TASK-audio-dump.md @@ -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 микрофона: "). +- При `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-дампа: ". + +### 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-дампа (телефон): ". + +### 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) — + «убожество» воспроизводимо в оффлайне? diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt index bb6f588..37f1e46 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt @@ -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() } /** diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt new file mode 100644 index 0000000..75050a2 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt @@ -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() +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index f75707a..db476a4 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -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 } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt index a8a45dc..5c3ddc8 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt @@ -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() }