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`) ## Жесты (распознаёт RayNeo SDK, `TempleAction`)
### Вне распознавания (обычный режим)
| Жест (SDK) | Режим КИНО | Режим ЧАТ | | Жест (SDK) | Режим КИНО | Режим ЧАТ |
|------------|-----------|-----------| |------------|-----------|-----------|
| `Click` | пауза ↔ плей | старт / стоп диктовки | | `Click` | пауза ↔ плей | старт / стоп диктовки |
| `DoubleClick` | игнор | отмена диктовки; вне диктовки — вызов локального ассистента | | `DoubleClick` | игнор | отмена диктовки |
| `TripleClick` | **безусловный вызов ассистента** (пауза видео + затемнение + слушаем) | **безусловный вызов ассистента** (слушаем) |
| `SlideBackward` / `SlideForward` | перемотка | в `SESSION_SELECT` — листание сессий; в `TALKING` — скролл текста ответа | | `SlideBackward` / `SlideForward` | перемотка | в `SESSION_SELECT` — листание сессий; в `TALKING` — скролл текста ответа |
| `LongClick` | (резерв) | показать / спрятать текст последнего ответа | | `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(libs.kotlinx.serialization.json)
implementation(project(":lib-core")) implementation(project(":lib-core"))
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
// WS-сервер телефона (CIO — работает на Android) // WS-сервер телефона (CIO — работает на Android)
implementation("io.ktor:ktor-server-core:3.3.0") implementation("io.ktor:ktor-server-core:3.3.0")
implementation("io.ktor:ktor-server-cio:3.3.0") implementation("io.ktor:ktor-server-cio:3.3.0")
@@ -1,9 +1,15 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.ui.ViewMateApp import pw.binom.viewmate.phone.ui.ViewMateApp
import java.io.File
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,5 +17,46 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
ViewMateApp() 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.