feat(app-phone): офлайн-распознавание речи — sherpa-onnx + Whisper-small

STT-модуль для отладки (без UI): WhisperStt (offline whisper int8, CPU),
VadSegmenter (Silero VAD, фразы), WavReader (16k mono s16), SttDebug
(распознавание wav-файла по фразам с печатью в лог). Вызов: am start
--ez stt_debug true (первый wav из filesDir/samples). Модели — в
filesDir/models (вне git): small-encoder/decoder.int8.onnx,
small-tokens.txt, silero_vad.onnx (fp32!).

Грабли, найденные на эмуляторе (Android 15):
- Silero VAD int8 (643КБ) молчит в Android-рантайме — нужна fp32 (2.2МБ)
- WavReader: littleShort возвращал беззнаковое 0..65535 — сэмплы с
  DC-offset, whisper давал мусор; теперь знаковый -32768..32767
- VAD требует подачу окнами ровно window_size (целиком — переполнение)
- Модели кладутся через adb push /data/local/tmp + run-as cp
  (внешний каталог приложения на Android 15 не видит adb-файлы)

Проверено на эмуляторе (CPU): «раз два три четыре пять» → «Раз, два 3 4 5»,
грека → почти идеально, masha/anton → 21 фраза, диалог распознан.
README: таблица жестов обновлена (TripleClick — безусловный вызов).
This commit is contained in:
Hermes Agent
2026-08-20 03:29:57 +03:00
parent 97aaabeb65
commit 324c427c74
11 changed files with 330 additions and 3 deletions
@@ -1,9 +1,15 @@
package pw.binom.viewmate.phone
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.ui.ViewMateApp
import java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,5 +17,46 @@ class MainActivity : ComponentActivity() {
setContent {
ViewMateApp()
}
if (intent?.getBooleanExtra("stt_debug", false) == true) {
runSttDebug()
}
}
/** Отладочный прогон распознавания: берёт первый wav из filesDir/samples. */
private fun runSttDebug() {
val samplesDir = File(filesDir, "samples")
val wav = samplesDir.listFiles()
?.filter { it.isFile && it.extension.equals("wav", true) }
?.firstOrNull()
if (wav == null) {
val msg = "[stt] нет wav-файлов в ${samplesDir.absolutePath}"
log("stt", msg)
showOverlay(msg)
return
}
showOverlay("[stt] распознаю ${wav.name}...")
Thread {
val text = SttDebug.run(this, wav.absolutePath)
runOnUiThread { showOverlay(text ?: "[stt] не удалось распознать") }
}.start()
}
/** TextView поверх экрана — чтобы видеть результат на эмуляторе. */
private fun showOverlay(text: String) {
val tv = TextView(this).apply {
this.text = text
setTextColor(Color.WHITE)
setBackgroundColor(Color.argb(200, 0, 0, 0))
textSize = 16f
setPadding(24, 24, 24, 24)
}
addContentView(
tv,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP,
),
)
}
}