STT: очки распознают речь локально и шлют готовый текст (SttText) в телефон
- glasses: локальный ASR через общий pw.binom.asr (GlassesAsr/GlassesSttSession/SttModelProvider) - protocol: SttText — очки присылают распознанный текст в телефон; телефон кидает его в LLM (старый SttAudio/StopStt оставлен фолбэком для старых очей) - phone: SttStreamer делегирует общему ASR; GlassesServer роутит SttText в LLM - lib-core: AudioPrep общий
This commit is contained in:
@@ -42,17 +42,19 @@ class BtGlassesTransport(
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* Runtime-пермишены, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12) + RECORD_AUDIO:
|
||||
* микрофон локального STT — без него AudioRecord не стартует и сессия
|
||||
* молча падает в файловую эмуляцию (пустой буфер).
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||
*/
|
||||
fun runtimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
(if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}
|
||||
}) + Manifest.permission.RECORD_AUDIO
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||
|
||||
@@ -11,6 +11,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
import pw.binom.viewmate.core.log.FileLogSpool
|
||||
import pw.binom.viewmate.core.log.SystemClock
|
||||
import pw.binom.viewmate.glasses.stt.GlassesAsr
|
||||
import pw.binom.viewmate.glasses.stt.GlassesSttSession
|
||||
import pw.binom.viewmate.glasses.stt.SttModelDownloader
|
||||
|
||||
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||
data class ChatEntry(val role: String, val text: String)
|
||||
@@ -51,6 +54,19 @@ class GlassesApp : Application() {
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
|
||||
/** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */
|
||||
lateinit var sttModel: SttModelDownloader
|
||||
private set
|
||||
|
||||
/** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */
|
||||
lateinit var asr: GlassesAsr
|
||||
private set
|
||||
|
||||
/** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */
|
||||
lateinit var sttSession: GlassesSttSession
|
||||
private set
|
||||
|
||||
|
||||
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
||||
lateinit var logCollector: BatchingLogCollector
|
||||
private set
|
||||
@@ -157,6 +173,21 @@ class GlassesApp : Application() {
|
||||
attachLogCollector(logCollector)
|
||||
pw.binom.viewmate.core.logCollector = logCollector
|
||||
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
|
||||
|
||||
// Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range).
|
||||
sttModel = SttModelDownloader(File(filesDir, "models"), bgScope)
|
||||
sttModel.ensure()
|
||||
|
||||
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
|
||||
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity.
|
||||
asr = GlassesAsr(File(filesDir, "models"))
|
||||
sttSession = GlassesSttSession(
|
||||
filesDir = filesDir,
|
||||
scope = bgScope,
|
||||
recognizer = { asr.recognize(it) },
|
||||
modelReady = { sttModel.status.value.ready },
|
||||
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -48,15 +48,13 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.core.protocol.SttText
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
||||
import pw.binom.viewmate.glasses.gestures.actionName
|
||||
import pw.binom.viewmate.glasses.gestures.assistantMode
|
||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
||||
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||
import pw.binom.viewmate.glasses.ui.Binocular
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||
@@ -70,11 +68,6 @@ import pw.binom.viewmate.glasses.ui.panelOffsetX
|
||||
class MainActivity : BaseEventActivity() {
|
||||
private var resumeAfterScreenOn = false
|
||||
|
||||
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
|
||||
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
|
||||
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
|
||||
}
|
||||
|
||||
/**
|
||||
* DEBUG-имитация жестов (эмулятор без трекпада):
|
||||
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
|
||||
@@ -265,7 +258,7 @@ class MainActivity : BaseEventActivity() {
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/** Click (кино): плей/пауза — как сегодня. */
|
||||
@@ -276,15 +269,13 @@ class MainActivity : BaseEventActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
|
||||
private fun sendPhrase() {
|
||||
log("gesture", "Click → отправить фразу")
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.listening.value = false
|
||||
GlassesApp.instance.thinking.value = true
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||
}
|
||||
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = true
|
||||
app.sttSession.send()
|
||||
}
|
||||
|
||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||
@@ -296,7 +287,7 @@ class MainActivity : BaseEventActivity() {
|
||||
app.thinking.value = false
|
||||
app.answerScrollDelta.value = 0f
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,7 +299,7 @@ class MainActivity : BaseEventActivity() {
|
||||
val app = GlassesApp.instance
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
app.sttSession.start()
|
||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||
}
|
||||
|
||||
@@ -321,13 +312,38 @@ class MainActivity : BaseEventActivity() {
|
||||
private fun cancelDictation() {
|
||||
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
|
||||
val app = GlassesApp.instance
|
||||
sttMic.stop()
|
||||
app.sttSession.cancel()
|
||||
app.sttText.value = ""
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
||||
app.sttSession.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
|
||||
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
|
||||
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
|
||||
* слушать, пустой после лимита — выход из режима.
|
||||
*/
|
||||
fun onSttResult(text: String, auto: Boolean) {
|
||||
val app = GlassesApp.instance
|
||||
app.sttText.value = text
|
||||
when {
|
||||
text.isNotBlank() -> {
|
||||
app.listening.value = false
|
||||
app.thinking.value = true
|
||||
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон")
|
||||
lifecycleScope.launch {
|
||||
app.hostConnection.sendToHost(SttText(text))
|
||||
}
|
||||
}
|
||||
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
|
||||
else -> {
|
||||
log("stt", "клик без голоса — остаюсь в «слушаю»")
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
app.sttSession.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,10 +356,10 @@ class MainActivity : BaseEventActivity() {
|
||||
*/
|
||||
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||
|
||||
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
||||
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
|
||||
private fun finishAssistantMode(reason: String) {
|
||||
log("gesture", reason)
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.sttSession.stop()
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = false
|
||||
|
||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses.gestures
|
||||
|
||||
import com.rayneo.arsdk.android.touch.TempleAction
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||
|
||||
/**
|
||||
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||
@@ -159,6 +160,19 @@ fun overlayHeader(state: AssistantState): String? = when {
|
||||
else -> state.sttText.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка статуса модели Qwen3 для оверлея (показывается, когда модель не READY и
|
||||
* микрофон открыт — распознавание гарантированно пустое): null — модель готова
|
||||
* (ничего не показывать); DOWNLOADING — «качается N%»; MISSING — не скачана;
|
||||
* FAILED — не скачалась (пойдут авто-повторы). Чистая функция — юнит-тесты без Android.
|
||||
*/
|
||||
fun modelStatusLine(status: SttModelStatus): String? = when (status.phase) {
|
||||
SttModelStatus.Phase.READY -> null
|
||||
SttModelStatus.Phase.DOWNLOADING -> "модель качается: ${status.percent}%"
|
||||
SttModelStatus.Phase.MISSING -> "модель не скачана — распознавание недоступно"
|
||||
SttModelStatus.Phase.FAILED -> "модель не скачалась — повторю автоматически"
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
|
||||
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import pw.binom.asr.qwen3.Qwen3Config
|
||||
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/**
|
||||
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
|
||||
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
|
||||
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
|
||||
* [SttModelDownloader]).
|
||||
*/
|
||||
class GlassesAsr(
|
||||
modelsDir: File,
|
||||
numThreads: Int = 4,
|
||||
) {
|
||||
private val modelsDir = modelsDir
|
||||
private val numThreads = numThreads
|
||||
|
||||
/**
|
||||
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
|
||||
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
|
||||
*/
|
||||
private val recognizer by lazy {
|
||||
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
|
||||
createQwen3Sherpa(
|
||||
Qwen3Config(
|
||||
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
|
||||
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
|
||||
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
|
||||
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
|
||||
numThreads = numThreads,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
||||
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
|
||||
recognizer.recognize(samples, sampleRate)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
import pw.binom.viewmate.core.stt.splitPhrases
|
||||
import pw.binom.viewmate.core.stt.trimEdges
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
|
||||
interface GlassesMic {
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean)
|
||||
fun stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
||||
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
||||
*
|
||||
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
||||
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
|
||||
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
|
||||
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
|
||||
* пусто после 30s-лимита (timeout) — автозавершение.
|
||||
*
|
||||
* @param onResult результат распознавания: text — фактический текст (может быть
|
||||
* пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout).
|
||||
*/
|
||||
class GlassesSttSession(
|
||||
private val filesDir: File,
|
||||
private val scope: CoroutineScope,
|
||||
private val recognizer: (FloatArray) -> String,
|
||||
private val modelReady: () -> Boolean,
|
||||
private val onResult: (text: String, auto: Boolean) -> Unit,
|
||||
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
|
||||
) {
|
||||
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
|
||||
private val sessionLimitMs = 30_000L
|
||||
|
||||
private var mic: GlassesMic? = null
|
||||
|
||||
/**
|
||||
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
|
||||
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
|
||||
*/
|
||||
private val pcmLock = ReentrantLock()
|
||||
private var pcm = ByteArray(0)
|
||||
private var watchdog: Job? = null
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
val isRunning: Boolean get() = running
|
||||
|
||||
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
||||
mic = m
|
||||
m.start(filesDir) { running }
|
||||
watchdog = scope.launch {
|
||||
delay(sessionLimitMs)
|
||||
finish(auto = true)
|
||||
}
|
||||
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
|
||||
}
|
||||
|
||||
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
|
||||
fun send() = finish(auto = false)
|
||||
|
||||
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
|
||||
fun cancel() {
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
log("stt", "STT-сессия очков: отмена")
|
||||
}
|
||||
|
||||
/** Полный стоп (выход из режима ассистента). */
|
||||
fun stop() {
|
||||
running = false
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
log("stt", "STT-сессия очков: закрыта")
|
||||
}
|
||||
|
||||
private fun finish(auto: Boolean) {
|
||||
if (!running) return
|
||||
running = false
|
||||
stopMic()
|
||||
watchdog?.cancel()
|
||||
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val text = if (audio.isNotEmpty() && modelReady()) {
|
||||
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
|
||||
phrases.joinToString(" ") { recognizer(it) }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
withContext(Dispatchers.Main) { onResult(text, auto) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopMic() {
|
||||
mic?.stop()
|
||||
mic = null
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,13 @@ import kotlin.math.roundToInt
|
||||
class SttMicStream(
|
||||
private val scope: CoroutineScope,
|
||||
private val onChunk: suspend (ByteArray) -> Unit,
|
||||
) {
|
||||
) : GlassesMic {
|
||||
private var job: Job? = null
|
||||
|
||||
/** Debug-дампер микрофона: СЫРОЙ поток (до усиления) — что реально слышат очки (по маркеру stt_dump.marker). */
|
||||
private var dump: WavWriter? = null
|
||||
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
override fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
job?.cancel()
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
@@ -96,7 +96,7 @@ class SttMicStream(
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
override fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Модель Qwen3-ASR 0.6B int8 для очек: 4 компонента в [filesDir]/models
|
||||
* (та же иерархия, что на телефоне — `PhoneApp.ensureStt`: conv/encoder/decoder +
|
||||
* tokenizer/). Скачиваются с зеркала [SttModel.BASE_URL] при отсутствии локально:
|
||||
* докачка с места обрыва (Range), проверка SHA-256, сквозной прогресс.
|
||||
*
|
||||
* Зеркало — внутренний `static.binom.pw/models/qwen3_06/` (обычная иерархия
|
||||
* корневых .onnx + подкаталог tokenizer/), URL зашит константой.
|
||||
*/
|
||||
object SttModel {
|
||||
/** Корень зеркала модели: обычные имена файлов + подкаталог tokenizer/. */
|
||||
const val BASE_URL = "https://static.binom.pw/models/qwen3_06"
|
||||
|
||||
/** Иерархия модели + размер и SHA-256 каждого файла (контроль целостности). */
|
||||
internal val FILES: List<ModelFile> = listOf(
|
||||
ModelFile("conv_frontend.onnx", 44_148_281L, "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"),
|
||||
ModelFile("encoder.int8.onnx", 182_491_662L, "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"),
|
||||
ModelFile("decoder.int8.onnx", 755_914_231L, "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"),
|
||||
ModelFile("tokenizer/merges.txt", 1_671_853L, "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"),
|
||||
ModelFile("tokenizer/vocab.json", 2_776_833L, "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"),
|
||||
ModelFile("tokenizer/tokenizer_config.json", 12_487L, "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Один файл модели: относительный путь (в models/), размер и SHA-256. */
|
||||
internal data class ModelFile(
|
||||
val relPath: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256Hex: String,
|
||||
)
|
||||
|
||||
/** Быстрый «довольны ли файлом»: есть + размер совпадает (SHA проверяем на докатке). */
|
||||
internal fun isSatisfied(modelsDir: File, f: ModelFile): Boolean {
|
||||
val final = File(modelsDir, f.relPath)
|
||||
return final.isFile && final.length() == f.sizeBytes
|
||||
}
|
||||
|
||||
/** Какие файлы [files] нужны (отсутствуют или битые по размеру). */
|
||||
internal fun whichFilesNeeded(modelsDir: File, files: List<ModelFile>): List<ModelFile> =
|
||||
files.filter { !isSatisfied(modelsDir, it) }
|
||||
|
||||
/** Модель на диске полностью: все файлы [files] на месте с верным размером. */
|
||||
internal fun isModelComplete(modelsDir: File, files: List<ModelFile> = SttModel.FILES): Boolean =
|
||||
whichFilesNeeded(modelsDir, files).isEmpty()
|
||||
|
||||
/** Все байты всех файлов модели (для сквозного процента). */
|
||||
internal fun modelTotalBytes(files: List<ModelFile> = SttModel.FILES): Long = files.sumOf { it.sizeBytes }
|
||||
|
||||
/** SHA-256 файла (потоком, без загрузки всего в память). */
|
||||
internal fun sha256Hex(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
digest.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
/** Статус модели: что происходит со скачиванием (для индикатора в UI очков). */
|
||||
data class SttModelStatus(
|
||||
val phase: Phase,
|
||||
val percent: Int = 0,
|
||||
val detail: String = "",
|
||||
) {
|
||||
enum class Phase { MISSING, DOWNLOADING, READY, FAILED }
|
||||
|
||||
val ready: Boolean get() = phase == Phase.READY
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание модели Qwen3-ASR в [modelsDir] (filesDir/models). Прогресс — в [status].
|
||||
* Идиомпотентно: [ensure] не делает ничего, если модель уже на месте; иначе докачивает
|
||||
* недостающие/битые файлы с Range и проверяет SHA-256.
|
||||
*/
|
||||
class SttModelDownloader(
|
||||
private val modelsDir: File,
|
||||
private val scope: CoroutineScope,
|
||||
private val okHttp: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // большие файлы, паузы в потоке
|
||||
.build(),
|
||||
) {
|
||||
private val _status = MutableStateFlow(SttModelStatus(SttModelStatus.Phase.MISSING))
|
||||
val status: StateFlow<SttModelStatus> = _status.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var downloadJob: Job? = null
|
||||
|
||||
/** Таймер авто-повтора после ошибки (сеть могла появиться позже — см. [scheduleRetry]). */
|
||||
@Volatile
|
||||
private var retryJob: Job? = null
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Пауза до авто-повтора скачивания после FAILED: сеть могла появиться позже. */
|
||||
private const val RETRY_DELAY_MS = 15_000L
|
||||
}
|
||||
|
||||
/** Перечитать с диска: READY (готово) или MISSING (не хватает). */
|
||||
fun refresh() {
|
||||
_status.value = if (isModelComplete(modelsDir)) {
|
||||
SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||
} else {
|
||||
SttModelStatus(SttModelStatus.Phase.MISSING, overallPercent(), "нет локально — скачаю при старте")
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачать недостающее (idempotent; повторный вызов пока идёт — игнор). */
|
||||
fun ensure() {
|
||||
if (isModelComplete(modelsDir)) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
if (downloadJob?.isActive == true) return
|
||||
downloadJob = scope.launch {
|
||||
try {
|
||||
val total = modelTotalBytes()
|
||||
val needed = whichFilesNeeded(modelsDir, SttModel.FILES)
|
||||
var done = total - needed.sumOf { it.sizeBytes } // байты уже готовых файлов
|
||||
needed.forEach { f ->
|
||||
currentCoroutineContext().ensureActive()
|
||||
downloadOne(f) { fileDone ->
|
||||
val overall = ((done + fileDone) * 100 / total).toInt().coerceIn(0, 100)
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.DOWNLOADING, overall, "${f.relPath}: $overall%")
|
||||
}
|
||||
done += f.sizeBytes
|
||||
log("stt", "модель: скачан ${f.relPath} (${f.sizeBytes} Б)")
|
||||
}
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||
log("stt", "модель Qwen3-ASR готова в ${modelsDir.absolutePath}")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("stt", "ошибка скачивания модели: ${e.message}")
|
||||
_status.value = SttModelStatus(SttModelStatus.Phase.FAILED, overallPercent(), "ошибка: ${e.message}")
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-повтор после [SttModelStatus.Phase.FAILED]: сеть могла появиться позже
|
||||
* (Wi-Fi очков выключен при старте, DNS не резолвится — фиксированные случаи).
|
||||
* Один таймер: новый провал перезапускает его; при готовности/идущем скачивании
|
||||
* [ensure] — no-op, так что повторы сами завершаются.
|
||||
*/
|
||||
private fun scheduleRetry() {
|
||||
retryJob?.cancel()
|
||||
retryJob = scope.launch {
|
||||
delay(RETRY_DELAY_MS)
|
||||
log("stt", "модель: авто-повтор скачивания (ошибка могла быть сессионной — сеть)")
|
||||
ensure()
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачать один файл: Range-докачка в .part, SHA-256, rename в финальный. onProgress — байты файла. */
|
||||
private suspend fun downloadOne(f: ModelFile, onProgress: (Long) -> Unit) {
|
||||
val parent = File(modelsDir, f.relPath).parentFile
|
||||
parent?.mkdirs()
|
||||
val final = File(modelsDir, f.relPath)
|
||||
val part = File(modelsDir, "$f.relPath.part")
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
|
||||
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
|
||||
if (existing > 0) builder.header("Range", "bytes=$existing-")
|
||||
|
||||
okHttp.newCall(builder.build()).execute().use { response ->
|
||||
val code = response.code
|
||||
if (code != 200 && code != 206) error("HTTP $code для ${f.relPath}")
|
||||
val body = response.body
|
||||
|
||||
val resumed = code == 206 && existing > 0
|
||||
val input = body.byteStream()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
var done = if (resumed) existing else 0L
|
||||
onProgress(done)
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
onProgress(done)
|
||||
}
|
||||
}
|
||||
|
||||
if (sha256Hex(part) != f.sha256Hex) {
|
||||
part.delete()
|
||||
error("SHA-256 не совпал для ${f.relPath}")
|
||||
}
|
||||
if (final.exists()) final.delete()
|
||||
if (!part.renameTo(final)) {
|
||||
part.copyTo(final, overwrite = true)
|
||||
part.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сквозной процент: сколько всех байтов модели уже на диске (по размеру). */
|
||||
private fun overallPercent(): Int {
|
||||
val total = modelTotalBytes()
|
||||
if (total <= 0) return 0
|
||||
val have = SttModel.FILES.filter { isSatisfied(modelsDir, it) }.sumOf { it.sizeBytes }
|
||||
return (have * 100 / total).toInt().coerceIn(0, 100)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import pw.binom.viewmate.glasses.ChatEntry
|
||||
import pw.binom.viewmate.glasses.GlassesApp
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||
import pw.binom.viewmate.glasses.gestures.modelStatusLine
|
||||
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||
|
||||
@@ -55,6 +56,10 @@ fun AssistantOverlay() {
|
||||
val chatHistory by app.chatHistory.collectAsState()
|
||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||
if (!overlayVisible(state)) return
|
||||
val modelStatus by app.sttModel.status.collectAsState()
|
||||
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
|
||||
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
|
||||
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null
|
||||
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||
@@ -81,7 +86,7 @@ fun AssistantOverlay() {
|
||||
}
|
||||
}
|
||||
|
||||
overlayBox(app, state, chatHistory, scrollState)
|
||||
overlayBox(app, state, chatHistory, scrollState, modelLine)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +102,7 @@ private fun overlayBox(
|
||||
state: AssistantState,
|
||||
chatHistory: List<ChatEntry>,
|
||||
scrollState: ScrollState,
|
||||
modelLine: String?,
|
||||
) {
|
||||
Binocular(scale = false) {
|
||||
val scale by app.scale.collectAsState()
|
||||
@@ -119,7 +125,7 @@ private fun overlayBox(
|
||||
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
||||
),
|
||||
) {
|
||||
AssistantOverlayContent(state, chatHistory, scrollState)
|
||||
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
|
||||
|
||||
if (state.listening) {
|
||||
Box(
|
||||
@@ -140,6 +146,7 @@ private fun AssistantOverlayContent(
|
||||
state: AssistantState,
|
||||
chatHistory: List<ChatEntry>,
|
||||
scrollState: ScrollState,
|
||||
modelLine: String?,
|
||||
) {
|
||||
val hasHistory = chatHistory.isNotEmpty()
|
||||
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
||||
@@ -160,6 +167,17 @@ private fun AssistantOverlayContent(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
// Строка статуса модели (только «слушаю» + модель не READY) — объясняет, почему
|
||||
// распознавание сейчас не работает и что происходит (качается / не скачалась).
|
||||
if (!modelLine.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = modelLine,
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
if (hasHistory) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(
|
||||
|
||||
Reference in New Issue
Block a user