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 android.media.MediaRecorder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -27,6 +29,9 @@ class SttMicStream(
|
|||||||
) {
|
) {
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
|
/** Debug-дампер сырого микрофона (открывается по маркеру stt_dump.marker). */
|
||||||
|
private var dump: WavWriter? = null
|
||||||
|
|
||||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
val recorder = runCatching {
|
val recorder = runCatching {
|
||||||
@@ -42,10 +47,13 @@ class SttMicStream(
|
|||||||
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
|
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
recorder?.release()
|
recorder?.release()
|
||||||
log("stt", "микрофон недоступен — отладка: PCM из файла")
|
log("stt", "микрофон недоступен — отладка: PCM из файла")
|
||||||
|
dump = null
|
||||||
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
|
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dump = openLiveMicDump(filesDir)
|
||||||
|
val dumpWriter = dump
|
||||||
job = scope.launch(Dispatchers.IO) {
|
job = scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
recorder.startRecording()
|
recorder.startRecording()
|
||||||
@@ -55,6 +63,10 @@ class SttMicStream(
|
|||||||
val n = recorder.read(buf, 0, buf.size)
|
val n = recorder.read(buf, 0, buf.size)
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
val chunk = if (n == buf.size) buf else buf.copyOf(n)
|
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)
|
onChunk(chunk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +75,8 @@ class SttMicStream(
|
|||||||
} finally {
|
} finally {
|
||||||
runCatching { recorder.stop() }
|
runCatching { recorder.stop() }
|
||||||
recorder.release()
|
recorder.release()
|
||||||
|
runCatching { dumpWriter?.close() }
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
log("stt", "микрофон остановлен")
|
log("stt", "микрофон остановлен")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +85,24 @@ class SttMicStream(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
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,
|
tokensPath = tokens.absolutePath,
|
||||||
numThreads = 4,
|
numThreads = 4,
|
||||||
)
|
)
|
||||||
|
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
||||||
|
File(filesDir, "stt_dump_phone.raw")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
val s = SttStreamer(
|
val s = SttStreamer(
|
||||||
stt = whisper,
|
stt = whisper,
|
||||||
vad = SherpaStreamingVad(vadModel.absolutePath),
|
vad = SherpaStreamingVad(vadModel.absolutePath),
|
||||||
@@ -378,8 +383,10 @@ class PhoneApp : Application() {
|
|||||||
log("stt", "тишина 30 с — автo-отмена")
|
log("stt", "тишина 30 с — автo-отмена")
|
||||||
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||||
},
|
},
|
||||||
|
dumpPcm = dumpPcm,
|
||||||
)
|
)
|
||||||
sttStreamer = s
|
sttStreamer = s
|
||||||
|
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||||
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package pw.binom.viewmate.phone.stt
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
import pw.binom.viewmate.phone.log
|
||||||
|
|
||||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||||
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||||
@@ -37,6 +40,7 @@ class SttStreamer(
|
|||||||
private val vad: StreamingVad,
|
private val vad: StreamingVad,
|
||||||
private val onPhrase: (phrase: String, full: String) -> Unit,
|
private val onPhrase: (phrase: String, full: String) -> Unit,
|
||||||
private val onSilence30s: () -> Unit,
|
private val onSilence30s: () -> Unit,
|
||||||
|
private val dumpPcm: File? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val SILENCE_TIMEOUT_MS = 30_000L
|
private const val SILENCE_TIMEOUT_MS = 30_000L
|
||||||
@@ -60,6 +64,18 @@ class SttStreamer(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var deadlineMs = 0L
|
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. */
|
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
||||||
private var pending = FloatArray(0)
|
private var pending = FloatArray(0)
|
||||||
|
|
||||||
@@ -109,6 +125,11 @@ class SttStreamer(
|
|||||||
}
|
}
|
||||||
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
|
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
|
deadlineMs = 0
|
||||||
vad.close()
|
vad.close()
|
||||||
}
|
}
|
||||||
|
runCatching { dumpOut?.close() }
|
||||||
|
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||||
executor.shutdownNow()
|
executor.shutdownNow()
|
||||||
watchdog.interrupt()
|
watchdog.interrupt()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user