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`)
|
## Жесты (распознаёт 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`.
|
||||||
|
|
||||||
## Состояния ассистента (единые для обоих ассистентов)
|
## Состояния ассистента (единые для обоих ассистентов)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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