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:
@@ -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`.
|
||||
|
||||
## Состояния ассистента (единые для обоих ассистентов)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user