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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user