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,
),
)
}
}
@@ -0,0 +1,59 @@
package pw.binom.viewmate.phone.stt
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileInputStream
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
object SttDebug {
private const val TAG = "stt"
/**
* Распознаёт [wavPath] по фразам (VAD + Whisper).
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
*/
fun run(context: Context, wavPath: String): String? {
return try {
val wav = FileInputStream(File(wavPath)).use { WavReader.read(it) }
log("[stt] wav: $wavPath, ${wav.sampleRate} Гц, ${wav.channels} ch, ${wav.samples.size} сэмплов (${wav.samples.size / 16000.0}s)")
// Модели во внутреннем каталоге приложения. Для отладки кладём
// через: adb push /data/local/tmp/... + run-as pkg cp
val modelsDir = File(context.filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "small-tokens.txt").absolutePath
log("[stt] создаём VAD...")
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
log("[stt] фраз (VAD): ${segments.size}")
log("[stt] создаём Whisper-small (int8, CPU)...")
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4)
val parts = ArrayList<String>()
for ((i, seg) in segments.withIndex()) {
val startMs = seg.first / 16
val endMs = seg.last / 16
val phrase = stt.recognize(wav.samples.copyOfRange(seg.first, seg.last))
parts += phrase
log("[stt] фраза ${i + 1} [$startMs-$endMs ms]: $phrase")
}
val all = parts.filter { it.isNotBlank() }.joinToString(" ")
log("[stt] ВЕСЬ ТЕКСТ: $all")
all
} catch (t: Throwable) {
Log.e(TAG, "[stt] ошибка: ${t.message}", t)
t.printStackTrace()
null
}
}
private fun log(msg: String) {
Log.i(TAG, msg)
println(msg)
}
}
@@ -0,0 +1,51 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
/**
* Режет 16 кГц PCM на фразы (Silero VAD).
* Возвращает сегменты [startSample, endSample).
*/
object VadSegmenter {
fun vadSegments(samples: FloatArray, vadModelPath: String): List<IntRange> {
val vadConfig = VadModelConfig(
sileroVadModelConfig = SileroVadModelConfig(
model = vadModelPath,
threshold = 0.5f,
minSilenceDuration = 0.25f,
minSpeechDuration = 0.25f,
windowSize = 512,
maxSpeechDuration = 20f,
),
sampleRate = 16000,
numThreads = 1,
provider = "cpu",
)
val vad = Vad(null, vadConfig)
try {
// VAD требует подачу окнами ровно по window_size (подача целиком
// ломает сегментацию — буфер переполняется).
val windowSize = vadConfig.sileroVadModelConfig.windowSize
var off = 0
while (off < samples.size) {
val end = minOf(off + windowSize, samples.size)
vad.acceptWaveform(samples.copyOfRange(off, end))
off = end
}
vad.flush()
val segments = ArrayList<IntRange>()
while (!vad.empty()) {
val seg = vad.front()
segments += seg.start until (seg.start + seg.samples.size)
vad.pop()
}
return segments
} finally {
vad.release()
}
}
}
@@ -0,0 +1,88 @@
package pw.binom.viewmate.phone.stt
import java.io.InputStream
/**
* Читает WAV (16 кГц, mono, s16) в FloatArray в диапазоне [-1, 1].
* Свой маленький ридер без зависимостей.
*/
object WavReader {
/** Прочитать WAV из потока. */
fun read(stream: InputStream): Wav {
val riff = stream.readBytes(4)
require(riff.contentEquals("RIFF".toByteArray())) { "не WAV-файл (нет RIFF)" }
stream.skip(4) // chunk size
val wave = stream.readBytes(4)
require(wave.contentEquals("WAVE".toByteArray())) { "не WAV-файл (нет WAVE)" }
var sampleRate = 0
var channels = 0
var dataBytes: ByteArray = ByteArray(0)
while (true) {
val id = stream.readBytes(4)
if (id.isEmpty()) break
val size = stream.readLittleInt()
when (String(id, Charsets.US_ASCII)) {
"fmt " -> {
val fmt = stream.readBytes(size)
val audioFormat = littleShort(fmt, 0)
require(audioFormat == 1) { "поддерживается только PCM s16 (audioFormat=$audioFormat)" }
channels = littleShort(fmt, 2)
sampleRate = littleInt(fmt, 4)
if (size > 16) stream.skip((size - 16).toLong())
}
"data" -> {
dataBytes = stream.readBytes(size)
break
}
else -> stream.skip(size.toLong())
}
}
require(sampleRate == 16000) { "ожидается 16 кГц, а тут $sampleRate" }
require(channels == 1) { "ожидается mono, а тут $channels" }
val count = dataBytes.size / 2
val samples = FloatArray(count)
for (i in 0 until count) {
val s = littleShort(dataBytes, i * 2)
samples[i] = (s / 32768f).coerceIn(-1f, 1f)
}
return Wav(sampleRate, channels, samples)
}
private fun InputStream.readBytes(n: Int): ByteArray {
val buf = ByteArray(n)
var off = 0
while (off < n) {
val r = read(buf, off, n - off)
if (r < 0) break
off += r
}
return if (off == n) buf else buf.copyOf(off)
}
private fun InputStream.readLittleInt(): Int {
val b = readBytes(4)
return littleInt(b, 0)
}
private fun littleShort(b: ByteArray, off: Int): Int {
val u = (b[off].toInt() and 0xff) or ((b[off + 1].toInt() and 0xff) shl 8)
return if (u >= 0x8000) u - 0x10000 else u // знаковый: -32768..32767
}
private fun littleInt(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xff) or
((b[off + 1].toInt() and 0xff) shl 8) or
((b[off + 2].toInt() and 0xff) shl 16) or
((b[off + 3].toInt() and 0xff) shl 24)
}
data class Wav(
val sampleRate: Int,
val channels: Int,
val samples: FloatArray,
)
@@ -0,0 +1,46 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig
/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */
class WhisperStt(
private val encoderPath: String,
private val decoderPath: String,
private val tokensPath: String,
private val numThreads: Int = 4,
private val language: String = "ru",
) {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
whisper = OfflineWhisperModelConfig(
encoder = encoderPath,
decoder = decoderPath,
language = language,
task = "transcribe",
tailPaddings = -1,
),
tokens = tokensPath,
numThreads = numThreads,
provider = "cpu",
modelType = "whisper",
),
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}