diff --git a/README.md b/README.md index 128e60a..1bb6144 100644 --- a/README.md +++ b/README.md @@ -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`. ## Состояния ассистента (единые для обоих ассистентов) diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index d6b68ba..1a366ad 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -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") diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt index 6be50e3..e4d051a 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt @@ -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, + ), + ) } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt new file mode 100644 index 0000000..63bbfe0 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt @@ -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() + 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) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/VadSegmenter.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/VadSegmenter.kt new file mode 100644 index 0000000..e44ac66 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/VadSegmenter.kt @@ -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 { + 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() + while (!vad.empty()) { + val seg = vad.front() + segments += seg.start until (seg.start + seg.samples.size) + vad.pop() + } + return segments + } finally { + vad.release() + } + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WavReader.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WavReader.kt new file mode 100644 index 0000000..4d7409e --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WavReader.kt @@ -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, +) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt new file mode 100644 index 0000000..3d8e449 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt @@ -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() + } + } +} diff --git a/voice-samples/README.md b/voice-samples/README.md new file mode 100644 index 0000000..9b52370 --- /dev/null +++ b/voice-samples/README.md @@ -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 +``` diff --git a/voice-samples/masha-anton.wav b/voice-samples/masha-anton.wav new file mode 100644 index 0000000..314db5e Binary files /dev/null and b/voice-samples/masha-anton.wav differ diff --git a/voice-samples/raz-dva-tri.mp3 b/voice-samples/raz-dva-tri.mp3 new file mode 100644 index 0000000..4f36462 Binary files /dev/null and b/voice-samples/raz-dva-tri.mp3 differ diff --git a/voice-samples/skorogovorka-greka.mp3 b/voice-samples/skorogovorka-greka.mp3 new file mode 100644 index 0000000..798ce5c Binary files /dev/null and b/voice-samples/skorogovorka-greka.mp3 differ