From 17385d178417af60beb8326951a38c5d86e0db82 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 25 Aug 2026 19:57:33 +0300 Subject: [PATCH] =?UTF-8?q?stt(phone):=20Qwen3-ASR-0.6B=20int8=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20whisper-small=20(=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B0=20STT=5FBACKEND,=20h?= =?UTF-8?q?otwords=20=D1=82=D1=83=D0=BB=D1=81=D0=B5=D1=82/=D1=82=D1=83?= =?UTF-8?q?=D0=BB=D1=8B/=D1=83=D0=BC=D0=B5=D0=B5=D1=88=D1=8C)=20=E2=80=94?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20=D0=B2=20=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D1=83=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20(opencode=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=A2=D0=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK-qwen3-asr-stt.md | 158 ++++++++++++++++++ .../pw/binom/viewmate/phone/PhoneApp.kt | 55 ++++-- .../binom/viewmate/phone/stt/Qwen3AsrStt.kt | 51 ++++++ 3 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 TASK-qwen3-asr-stt.md create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt diff --git a/TASK-qwen3-asr-stt.md b/TASK-qwen3-asr-stt.md new file mode 100644 index 0000000..464da75 --- /dev/null +++ b/TASK-qwen3-asr-stt.md @@ -0,0 +1,158 @@ +# TASK: замена whisper-small на Qwen3-ASR-0.6B int8 (sherpa-onnx, CPU) + +## Проблема (доказана на железе 25.08, полевым тестом ac56e70 + серверным экспериментом) + +whisper-small int8 распознаёт русскую речь посредственно: «Какие тебе доступные +толулы?», «Ну давай, а что из этого туалета у тебя есть?» (было на дампах +пользователя). Qwen3-ASR-0.6B (тот же движок sherpa-onnx 1.13.6, тот же CPU) +без подсказок — ровня висперу, а с hotwords («тулсет,тулы,умеешь») исправляет +все провалы в ноль: «Какие тебе доступные тулы?», «Ну давай, а что из этого +тулсета у тебя есть?», «Что ты умеешь?». Эксперимент проведён на дампах +пользователя тем же движком и той же моделью (int8), скорость на серверном CPU +0.1-0.2 от реального времени (фраза 4.4 с → 0.9 с). Решение пользователя: +остаёмся на CPU (аппаратное ускорение NNAPI для трансформеров не даёт выигрыша). + +## Решение + +Новый класс `Qwen3AsrStt` (реализует тот же интерфейс `PhraseRecognizer`, что и +`WhisperStt`) + переключатель-константа в фабрике `PhoneApp.ensureStt()`. +Старый `WhisperStt` НЕ удалять — откат в одну строку. Сегментация по паузам +(`splitPhrases` + склейка пробелом в `SttStreamer`) НЕ меняется — Qwen3-ASR тоже +имеет лимит max_total_len=512 токенов (≈ 30-40 с аудио), нарезка остаётся в силе. + +## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать) + +1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt` — НОВЫЙ файл. +2. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt` — только + `ensureStt()` (строки ~350-366) + 2 константы в companion object (строка 512). + +ЗАПРЕЩЕНО трогать: `WhisperStt.kt`, `SttStreamer.kt`, `SttDebug.kt`, +`GlassesServer.kt`, `lib-core`, протокол, весь app-glasses. Все scratch-файлы — +вне репо или в коммит не включать. + +## 1. Qwen3AsrStt.kt — новый класс + +```kotlin +package pw.binom.viewmate.phone.stt + +import com.k2fsa.sherpa.onnx.OfflineModelConfig +import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig +import com.k2fsa.sherpa.onnx.OfflineRecognizer +import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig + +/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */ +class Qwen3AsrStt( + private val convFrontendPath: String, + private val encoderPath: String, + private val decoderPath: String, + private val tokenizerDir: String, + private val numThreads: Int = 4, + private val hotwords: String = "", +) : PhraseRecognizer { + private val recognizer: OfflineRecognizer = OfflineRecognizer( + null, + OfflineRecognizerConfig( + modelConfig = OfflineModelConfig( + qwen3Asr = OfflineQwen3AsrModelConfig( + convFrontend = convFrontendPath, + encoder = encoderPath, + decoder = decoderPath, + tokenizer = tokenizerDir, + maxTotalLen = 512, + maxNewTokens = 128, + temperature = 1e-6f, + topP = 0.8f, + seed = 42, + hotwords = hotwords, + ), + numThreads = numThreads, + provider = "cpu", + modelType = "qwen3_asr", + ), + ), + ) + + /** Распознать 16 кГц mono PCM. Возвращает текст. */ + override 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() + } + } +} +``` + +Точные имена полей и сигнатуры подтверждены байткодом sherpa-onnx v1.13.6 +(`OfflineQwen3AsrModelConfig` — поля convFrontend/encoder/decoder/tokenizer/ +maxTotalLen/maxNewTokens/temperature/topP/seed/hotwords; `OfflineModelConfig` — +поле qwen3Asr; нативная строка `qwen3_asr=` подтверждает modelType). +Дефолты декодирования — из C++-конфига sherpa-onnx 1.13.6: max_total_len=512, +max_new_tokens=128, temperature=1e-6, top_p=0.8, seed=42. Поле `tokens` в +OfflineModelConfig для qwen3_asr НЕ передавать (токенизатор — директория в +qwen3Asr.tokenizer, не файл). + +## 2. PhoneApp.kt — фабрика и константы + +В companion object (строка 512) добавить: + +```kotlin +/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */ +private const val STT_BACKEND = "qwen3_asr" + +/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */ +private const val STT_HOTWORDS = "тулсет,тулы,умеешь" +``` + +В `ensureStt()` (строки ~350-366) — заменить блок создания whisper на: + +```kotlin +val qwenConv = File(modelsDir, "conv_frontend.onnx") +val qwenEnc = File(modelsDir, "encoder.int8.onnx") +val qwenDec = File(modelsDir, "decoder.int8.onnx") +val qwenTok = File(modelsDir, "tokenizer") +if (STT_BACKEND == "qwen3_asr" && + qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory +) { + stt = Qwen3AsrStt( + convFrontendPath = qwenConv.absolutePath, + encoderPath = qwenEnc.absolutePath, + decoderPath = qwenDec.absolutePath, + tokenizerDir = qwenTok.absolutePath, + numThreads = 4, + hotwords = STT_HOTWORDS, + ) +} else { + val encoder = File(modelsDir, "small-encoder.int8.onnx") + val decoder = File(modelsDir, "small-decoder.int8.onnx") + val tokens = File(modelsDir, "small-tokens.txt") + if (!encoder.isFile || !decoder.isFile || !tokens.isFile) { + log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") + return null + } + stt = WhisperStt( + encoderPath = encoder.absolutePath, + decoderPath = decoder.absolutePath, + tokensPath = tokens.absolutePath, + numThreads = 4, + ) +} +``` + +Тип `stt` — `PhraseRecognizer` (локальная переменная в synchronized-блоке; +дальше `SttStreamer(stt = stt, ...)` как сейчас). Лог создания: добавить строку +`log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")` перед созданием +SttStreamer. Старая ветка whisper остаётся для отката. + +## Проверка (делает Hermes, не агент) + +- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные, + число тестов app-phone НЕ меняется (новый класс не покрывается JVM-тестами — + Android + нативная библиотека; splitPhrases/trimEdges не трогаются). +- `git status --short` чистый, коммит с реализацией и этим ТЗ есть. +- Дальше Hermes сам: adb push моделей (conv_frontend.onnx, encoder.int8.onnx, + decoder.int8.onnx, tokenizer/ — из /root/viewmate/models/sherpa-onnx-qwen3-asr-0.6B-int8/) + на телефон в filesDir/models + установка APK + полевая приёмка на железе. diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 41e567b..0793e86 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -46,6 +46,8 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient import pw.binom.viewmate.phone.agent.SkillRegistry import pw.binom.viewmate.phone.agent.SkillRepository import pw.binom.viewmate.phone.agent.ToolsetRegistry +import pw.binom.viewmate.phone.stt.PhraseRecognizer +import pw.binom.viewmate.phone.stt.Qwen3AsrStt import pw.binom.viewmate.phone.stt.SttStreamer import pw.binom.viewmate.phone.stt.WhisperStt @@ -351,26 +353,45 @@ class PhoneApp : Application() { synchronized(sttLock) { sttStreamer?.let { return it } val modelsDir = File(filesDir, "models") - val encoder = File(modelsDir, "small-encoder.int8.onnx") - val decoder = File(modelsDir, "small-decoder.int8.onnx") - val tokens = File(modelsDir, "small-tokens.txt") - if (!encoder.isFile || !decoder.isFile || !tokens.isFile) { - log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") - return null + val qwenConv = File(modelsDir, "conv_frontend.onnx") + val qwenEnc = File(modelsDir, "encoder.int8.onnx") + val qwenDec = File(modelsDir, "decoder.int8.onnx") + val qwenTok = File(modelsDir, "tokenizer") + var stt: PhraseRecognizer + if (STT_BACKEND == "qwen3_asr" && + qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory + ) { + stt = Qwen3AsrStt( + convFrontendPath = qwenConv.absolutePath, + encoderPath = qwenEnc.absolutePath, + decoderPath = qwenDec.absolutePath, + tokenizerDir = qwenTok.absolutePath, + numThreads = 4, + hotwords = STT_HOTWORDS, + ) + } else { + val encoder = File(modelsDir, "small-encoder.int8.onnx") + val decoder = File(modelsDir, "small-decoder.int8.onnx") + val tokens = File(modelsDir, "small-tokens.txt") + if (!encoder.isFile || !decoder.isFile || !tokens.isFile) { + log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") + return null + } + stt = WhisperStt( + encoderPath = encoder.absolutePath, + decoderPath = decoder.absolutePath, + tokensPath = tokens.absolutePath, + numThreads = 4, + ) } - val whisper = WhisperStt( - encoderPath = encoder.absolutePath, - decoderPath = decoder.absolutePath, - tokensPath = tokens.absolutePath, - numThreads = 4, - ) val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) { File(filesDir, "stt_dump_phone.raw") } else { null } + log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)") val s = SttStreamer( - stt = whisper, + stt = stt, onSilence30s = { log("stt", "тишина 30 с — автo-отмена") scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) } @@ -385,7 +406,7 @@ class PhoneApp : Application() { ) sttStreamer = s log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}") - log("stt", "SttStreamer готов (Whisper-small int8)") + log("stt", "SttStreamer готов ($STT_BACKEND)") return s } } @@ -512,5 +533,11 @@ class PhoneApp : Application() { companion object { lateinit var instance: PhoneApp private set + + /** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */ + private const val STT_BACKEND = "qwen3_asr" + + /** Подсказки словаря Qwen3-ASR (запятая-разделитель). */ + private const val STT_HOTWORDS = "тулсет,тулы,умеешь" } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt new file mode 100644 index 0000000..8f20555 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt @@ -0,0 +1,51 @@ +package pw.binom.viewmate.phone.stt + +import com.k2fsa.sherpa.onnx.OfflineModelConfig +import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig +import com.k2fsa.sherpa.onnx.OfflineRecognizer +import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig + +/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */ +class Qwen3AsrStt( + private val convFrontendPath: String, + private val encoderPath: String, + private val decoderPath: String, + private val tokenizerDir: String, + private val numThreads: Int = 4, + private val hotwords: String = "", +) : PhraseRecognizer { + private val recognizer: OfflineRecognizer = OfflineRecognizer( + null, + OfflineRecognizerConfig( + modelConfig = OfflineModelConfig( + qwen3Asr = OfflineQwen3AsrModelConfig( + convFrontend = convFrontendPath, + encoder = encoderPath, + decoder = decoderPath, + tokenizer = tokenizerDir, + maxTotalLen = 512, + maxNewTokens = 128, + temperature = 1e-6f, + topP = 0.8f, + seed = 42, + hotwords = hotwords, + ), + numThreads = numThreads, + provider = "cpu", + modelType = "qwen3_asr", + ), + ), + ) + + /** Распознать 16 кГц mono PCM. Возвращает текст. */ + override 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() + } + } +} \ No newline at end of file