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
+21 -3
View File
@@ -59,15 +59,33 @@ sketches/ эскизы UI: раскладка экранов + сценар
## Жесты (распознаёт RayNeo SDK, `TempleAction`)
### Вне распознавания (обычный режим)
| Жест (SDK) | Режим КИНО | Режим ЧАТ |
|------------|-----------|-----------|
| `Click` | пауза ↔ плей | старт / стоп диктовки |
| `DoubleClick` | игнор | отмена диктовки; вне диктовки — вызов локального ассистента |
| `DoubleClick` | игнор | отмена диктовки |
| `TripleClick` | **безусловный вызов ассистента** (пауза видео + затемнение + слушаем) | **безусловный вызов ассистента** (слушаем) |
| `SlideBackward` / `SlideForward` | перемотка | в `SESSION_SELECT` — листание сессий; в `TALKING` — скролл текста ответа |
| `LongClick` | (резерв) | показать / спрятать текст последнего ответа |
| `TripleClick` | (резерв) | (резерв) |
> Жесты распознаёт сам SDK (`TempleAction.Click/DoubleClick/TripleClick/SlideBackward/SlideForward/LongClick`) — своя детекция не нужна (проверено в pair-view, `MainActivity.kt`).
### Во время распознавания речи (LISTENING)
| Жест | Действие |
|------|----------|
| `Click` | **завершить распознавание**: вывести весь текст (все фразы) в консоль; затемнение уходит, видео остаётся на паузе |
| `DoubleClick` | **отмена распознавания** (текст отбрасывается) |
| `TripleClick` | игнор |
| VAD молчит 30 с | автоматическая отмена распознавания |
> **TripleClick — безусловный вызов локального ассистента из любого места**
> (поверх всего). Распознавание идёт по фразам: VAD отсёк фразу — распознал —
> отобразил на очках — слушаем дальше; ввод считается открытым до Click.
>
> Жесты распознаёт сам SDK (`TempleAction.Click/DoubleClick/SlideBackward/
> SlideForward/LongClick`) — своя детекция не нужна (проверено в pair-view,
> `MainActivity.kt`). `TripleClick`: если SDK его не отдаёт — детект по
> таймстампам кликов в `onTouchEvent`.
## Состояния ассистента (единые для обоих ассистентов)
+3
View File
@@ -54,6 +54,9 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core"))
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
// WS-сервер телефона (CIO — работает на Android)
implementation("io.ktor:ktor-server-core:3.3.0")
implementation("io.ktor:ktor-server-cio:3.3.0")
@@ -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()
}
}
}
+15
View File
@@ -0,0 +1,15 @@
# voice-samples — образцы голоса для тестов распознавания
Тестовые аудио для on-device STT (очки/телефон). Не удалять — образцы
пользователя, присылаются повторно только по необходимости.
| Файл | Что там | Длительность |
|---|---|---|
| `skorogovorka-greka.mp3` | «Ехал грека через реку…» (скороговорка) | ~6 с |
| `raz-dva-tri.mp3` | «раз два три четыре пять» | ~3 с |
| `masha-anton.wav` | Голоса Маши и Антона (с 142, 16 кГц mono) | 60 с |
Конвертация в 16 кГц mono (как для STT):
```
ffmpeg -y -i in.mp3 -ar 16000 -ac 1 out.wav
```
Binary file not shown.
Binary file not shown.
Binary file not shown.