Files
view-mate/TASK-qwen3-asr-stt.md
T

7.9 KiB
Raw Blame History

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 — новый класс

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