stt(phone): Qwen3-ASR-0.6B int8 вместо whisper-small (константа STT_BACKEND, hotwords тулсет/тулы/умеешь) — откат в одну строку (opencode по ТЗ)

This commit is contained in:
2026-08-25 19:57:33 +03:00
parent 98ac84ffe8
commit 17385d1784
3 changed files with 250 additions and 14 deletions
+158
View File
@@ -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 + полевая приёмка на железе.
@@ -46,6 +46,8 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient
import pw.binom.viewmate.phone.agent.SkillRegistry import pw.binom.viewmate.phone.agent.SkillRegistry
import pw.binom.viewmate.phone.agent.SkillRepository import pw.binom.viewmate.phone.agent.SkillRepository
import pw.binom.viewmate.phone.agent.ToolsetRegistry 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.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt import pw.binom.viewmate.phone.stt.WhisperStt
@@ -351,26 +353,45 @@ class PhoneApp : Application() {
synchronized(sttLock) { synchronized(sttLock) {
sttStreamer?.let { return it } sttStreamer?.let { return it }
val modelsDir = File(filesDir, "models") val modelsDir = File(filesDir, "models")
val encoder = File(modelsDir, "small-encoder.int8.onnx") val qwenConv = File(modelsDir, "conv_frontend.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx") val qwenEnc = File(modelsDir, "encoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt") val qwenDec = File(modelsDir, "decoder.int8.onnx")
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) { val qwenTok = File(modelsDir, "tokenizer")
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") var stt: PhraseRecognizer
return null 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()) { val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw") File(filesDir, "stt_dump_phone.raw")
} else { } else {
null null
} }
log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
val s = SttStreamer( val s = SttStreamer(
stt = whisper, stt = stt,
onSilence30s = { onSilence30s = {
log("stt", "тишина 30 с — автo-отмена") log("stt", "тишина 30 с — автo-отмена")
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) } scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
@@ -385,7 +406,7 @@ class PhoneApp : Application() {
) )
sttStreamer = s sttStreamer = s
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}") log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов (Whisper-small int8)") log("stt", "SttStreamer готов ($STT_BACKEND)")
return s return s
} }
} }
@@ -512,5 +533,11 @@ class PhoneApp : Application() {
companion object { companion object {
lateinit var instance: PhoneApp lateinit var instance: PhoneApp
private set private set
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
private const val STT_BACKEND = "qwen3_asr"
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
} }
} }
@@ -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()
}
}
}