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