stt: debug-дампы аудио-пути (очки: сырой микрофон в WAV; телефон: принятые PCM) по старту от stt_dump.marker
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user