stt(phone): Qwen3-ASR-0.6B int8 вместо whisper-small (константа STT_BACKEND, hotwords тулсет/тулы/умеешь) — откат в одну строку (opencode по ТЗ)
This commit is contained in:
@@ -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.SkillRepository
|
||||
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.WhisperStt
|
||||
|
||||
@@ -351,26 +353,45 @@ class PhoneApp : Application() {
|
||||
synchronized(sttLock) {
|
||||
sttStreamer?.let { return it }
|
||||
val modelsDir = File(filesDir, "models")
|
||||
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
|
||||
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")
|
||||
var stt: PhraseRecognizer
|
||||
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()) {
|
||||
File(filesDir, "stt_dump_phone.raw")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
|
||||
val s = SttStreamer(
|
||||
stt = whisper,
|
||||
stt = stt,
|
||||
onSilence30s = {
|
||||
log("stt", "тишина 30 с — автo-отмена")
|
||||
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||
@@ -385,7 +406,7 @@ class PhoneApp : Application() {
|
||||
)
|
||||
sttStreamer = s
|
||||
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||
log("stt", "SttStreamer готов (Whisper-small int8)")
|
||||
log("stt", "SttStreamer готов ($STT_BACKEND)")
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -512,5 +533,11 @@ class PhoneApp : Application() {
|
||||
companion object {
|
||||
lateinit var instance: PhoneApp
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user