# 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 + полевая приёмка на железе.