7.9 KiB
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 с аудио), нарезка остаётся в силе.
Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt— НОВЫЙ файл.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 — новый класс
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) добавить:
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
private const val STT_BACKEND = "qwen3_asr"
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
В ensureStt() (строки ~350-366) — заменить блок создания whisper на:
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 + полевая приёмка на железе.