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(
|
||||
|
||||
+28
@@ -15,6 +15,7 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||
|
||||
/**
|
||||
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
||||
@@ -227,6 +228,33 @@ class AssistantGesturesTest {
|
||||
assertNull(overlayHeader(movie))
|
||||
}
|
||||
|
||||
// ————— строка статуса модели —————
|
||||
|
||||
@Test
|
||||
fun modelStatusLineReadyIsNull() {
|
||||
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelStatusLineDownloadingShowsPercent() {
|
||||
assertEquals(
|
||||
"модель качается: 45%",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelStatusLineMissingAndFailed() {
|
||||
assertEquals(
|
||||
"модель не скачана — распознавание недоступно",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
|
||||
)
|
||||
assertEquals(
|
||||
"модель не скачалась — повторю автоматически",
|
||||
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
|
||||
)
|
||||
}
|
||||
|
||||
// ————— скролл ответа —————
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SttModelProviderTest {
|
||||
|
||||
private fun newModelDir(): File {
|
||||
val dir = Files.createTempDirectory("stt-model-test").toFile()
|
||||
dir.deleteOnExit()
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun smallFiles() = listOf(
|
||||
ModelFile("a.onnx", 4L, "sha-a"),
|
||||
ModelFile("tokenizer/tok.json", 5L, "sha-b"),
|
||||
)
|
||||
|
||||
private fun write(modelDir: File, relPath: String, size: Int) {
|
||||
val f = File(modelDir, relPath)
|
||||
f.parentFile?.mkdirs()
|
||||
f.writeBytes(ByteArray(size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifestIsSane() {
|
||||
assertEquals(6, SttModel.FILES.size)
|
||||
assertEquals(6, SttModel.FILES.map { it.relPath }.toSet().size, "пути дублируются")
|
||||
SttModel.FILES.forEach { f ->
|
||||
assertTrue(f.sha256Hex.length == 64, "некорректный SHA-256: ${f.relPath}")
|
||||
assertTrue(f.sizeBytes > 0, "нулевой размер: ${f.relPath}")
|
||||
}
|
||||
// Иерархия 1:1 с filesDir/models телефона: 3 корневых .onnx + tokenizer/ (3 файла).
|
||||
assertTrue(SttModel.FILES.any { it.relPath == "conv_frontend.onnx" })
|
||||
assertTrue(SttModel.FILES.any { it.relPath == "tokenizer/merges.txt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDirNeedsAllFiles() {
|
||||
val dir = newModelDir()
|
||||
val files = smallFiles()
|
||||
val needed = whichFilesNeeded(dir, files)
|
||||
assertEquals(files, needed)
|
||||
assertTrue(!isModelComplete(dir, files))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completeDirNeedsNothing() {
|
||||
val dir = newModelDir()
|
||||
val files = smallFiles()
|
||||
write(dir, "a.onnx", 4)
|
||||
write(dir, "tokenizer/tok.json", 5)
|
||||
assertEquals(emptyList(), whichFilesNeeded(dir, files))
|
||||
assertTrue(isModelComplete(dir, files))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongSizeTreatedAsNeeded() {
|
||||
val dir = newModelDir()
|
||||
val files = smallFiles()
|
||||
write(dir, "a.onnx", 3) // короче ожидаемых 4
|
||||
write(dir, "tokenizer/tok.json", 5)
|
||||
val needed = whichFilesNeeded(dir, files)
|
||||
assertEquals(listOf("a.onnx"), needed.map { it.relPath })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelTotalBytesIsSum() {
|
||||
assertEquals(9L, modelTotalBytes(smallFiles()))
|
||||
assertTrue(
|
||||
modelTotalBytes() in 900_000_000..1_000_000_000,
|
||||
"сумма байт модели не в ожидаемом порядке: ${modelTotalBytes()}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sha256HexOfKnownInput() {
|
||||
val dir = newModelDir()
|
||||
val f = File(dir, "known.bin")
|
||||
f.writeBytes("abc".encodeToByteArray())
|
||||
assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", sha256Hex(f))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.core.protocol.SttText
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
@@ -235,6 +236,19 @@ class GlassesHub(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Новый путь: очки распознали речь локально и прислали готовый текст —
|
||||
// в LLM тем же каналом, что и StopStt(cancel=false). Old SttAudio/StopStt
|
||||
// остаются фолбэком для старых очей.
|
||||
is SttText -> {
|
||||
val full = msg.text.trim()
|
||||
if (full.isNotBlank()) {
|
||||
log("stt", "STT-текст от очков (локально): $full → LLM")
|
||||
onStopFullText?.invoke(full)
|
||||
} else {
|
||||
log("stt", "STT-текст от очков пуст — ignore")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,169 +6,11 @@ import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.sqrt
|
||||
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.phone.log
|
||||
|
||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||
val n = pcm.size / 2
|
||||
val out = FloatArray(n)
|
||||
var i = 0
|
||||
for (j in 0 until n) {
|
||||
val lo = pcm[i].toInt() and 0xff
|
||||
val hi = pcm[i + 1].toInt() and 0xff
|
||||
i += 2
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
out[j] = (s / 32768f).coerceIn(-1f, 1f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
|
||||
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
|
||||
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
|
||||
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
|
||||
*
|
||||
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
|
||||
* Чистая функция (без Android) — покрыта JVM-тестами.
|
||||
*/
|
||||
internal fun trimEdges(
|
||||
samples: FloatArray,
|
||||
threshold: Float = 0.01f,
|
||||
windowSize: Int = 320,
|
||||
padWindows: Int = 15,
|
||||
): FloatArray {
|
||||
if (samples.isEmpty()) return samples
|
||||
var first = -1
|
||||
var lastEnd = -1
|
||||
var idx = 0
|
||||
while (idx < samples.size) {
|
||||
val end = minOf(idx + windowSize, samples.size)
|
||||
var sum = 0.0
|
||||
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
|
||||
val rms = sqrt(sum / (end - idx)).toFloat()
|
||||
if (rms >= threshold) {
|
||||
if (first == -1) first = idx
|
||||
lastEnd = end
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
if (first == -1) return FloatArray(0)
|
||||
val pad = padWindows * windowSize
|
||||
val start = maxOf(0, first - pad)
|
||||
val stop = minOf(samples.size, lastEnd + pad)
|
||||
return samples.copyOfRange(start, stop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||
* НЕ менять без перепроверки на тех же дампах.
|
||||
*
|
||||
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
|
||||
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
|
||||
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
|
||||
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
|
||||
* каждой стороны (clamp к границам массива). Фраза короче
|
||||
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
|
||||
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
|
||||
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||
* < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
|
||||
*/
|
||||
internal fun splitPhrases(
|
||||
samples: FloatArray,
|
||||
threshold: Float = 0.005f,
|
||||
windowSize: Int = 320, // 20 мс @ 16 кГц
|
||||
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
||||
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
||||
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
||||
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
||||
): List<FloatArray> {
|
||||
if (samples.isEmpty()) return emptyList()
|
||||
val winCount = (samples.size + windowSize - 1) / windowSize
|
||||
val isSound = BooleanArray(winCount)
|
||||
var anySound = false
|
||||
for (w in 0 until winCount) {
|
||||
val s = w * windowSize
|
||||
val e = minOf(s + windowSize, samples.size)
|
||||
var sum = 0.0
|
||||
for (k in s until e) sum += samples[k] * samples[k].toDouble()
|
||||
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
|
||||
isSound[w] = true
|
||||
anySound = true
|
||||
}
|
||||
}
|
||||
if (!anySound) return emptyList()
|
||||
|
||||
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
|
||||
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
|
||||
class Phrase(var start: Int, var end: Int)
|
||||
|
||||
val phrases = mutableListOf<Phrase>()
|
||||
var pos = 0
|
||||
while (pos < winCount) {
|
||||
var sil = 0
|
||||
while (pos + sil < winCount && !isSound[pos + sil]) sil++
|
||||
if (pos + sil >= winCount) break
|
||||
val start = pos + sil
|
||||
var end = start
|
||||
var continuePhrase = true
|
||||
while (continuePhrase) {
|
||||
var run = 0
|
||||
while (end + run < winCount && isSound[end + run]) run++
|
||||
end += run
|
||||
if (end >= winCount) { continuePhrase = false; break }
|
||||
var sil2 = 0
|
||||
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
|
||||
if (sil2 >= minSilenceWindows) {
|
||||
continuePhrase = false
|
||||
} else {
|
||||
end += sil2
|
||||
}
|
||||
}
|
||||
phrases.add(Phrase(start, end))
|
||||
pos = end
|
||||
}
|
||||
|
||||
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
|
||||
while (phrases.size > 1) {
|
||||
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
|
||||
if (idx < 0) break
|
||||
val other = if (idx == 0) idx + 1 else idx - 1
|
||||
val lo = minOf(idx, other)
|
||||
val hi = maxOf(idx, other)
|
||||
val merged = Phrase(
|
||||
minOf(phrases[lo].start, phrases[hi].start),
|
||||
maxOf(phrases[lo].end, phrases[hi].end),
|
||||
)
|
||||
phrases.removeAt(hi)
|
||||
phrases.removeAt(lo)
|
||||
phrases.add(lo, merged)
|
||||
}
|
||||
|
||||
val pad = padWindows * windowSize
|
||||
val out = mutableListOf<FloatArray>()
|
||||
for (p in phrases) {
|
||||
val s = maxOf(0, p.start * windowSize - pad)
|
||||
val e = minOf(samples.size, p.end * windowSize + pad)
|
||||
if (e - s <= maxPhraseSamples) {
|
||||
out += samples.copyOfRange(s, e)
|
||||
} else {
|
||||
val chunk = (maxPhraseSamples / windowSize) * windowSize
|
||||
var cs = s
|
||||
while (cs < e) {
|
||||
val ce = minOf(e, cs + chunk)
|
||||
out += samples.copyOfRange(cs, ce)
|
||||
cs = ce
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||
|
||||
@@ -3,6 +3,7 @@ package pw.binom.viewmate.phone.stt
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.viewmate.core.stt.splitPhrases
|
||||
|
||||
/**
|
||||
* Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на
|
||||
|
||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||
|
||||
class S16ToFloatTest {
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package pw.binom.viewmate.phone.stt
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.viewmate.core.stt.trimEdges
|
||||
|
||||
/**
|
||||
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
|
||||
|
||||
@@ -109,6 +109,16 @@ data class SttAudio(val data: ByteArray) : GlassesToHost {
|
||||
@SerialName("stop_stt")
|
||||
data class StopStt(val cancel: Boolean) : GlassesToHost
|
||||
|
||||
/**
|
||||
* Готовый распознанный текст (очки → телефон): очки сами распознали речь локально
|
||||
* (Qwen3-ASR) и шлют фактический текст — телефон маршрутизирует его в LLM.
|
||||
* Замена пути SttAudio/StopStt (там распознавал телефон); тот остаётся как фолбэк
|
||||
* для старых очей. Пустой текст очки не шлют.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("stt_text")
|
||||
data class SttText(val text: String) : GlassesToHost
|
||||
|
||||
/**
|
||||
* Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему
|
||||
* коллектору и пересылает на сервер вместе со своими лог-записями;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package pw.binom.viewmate.core.stt
|
||||
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Чистые (без Android) преобразования PCM перед офлайн-распознаванием — общие
|
||||
* для телефона и очков. Телефон накапливает и распознаёт на себе (фолбэк); очки
|
||||
* распозновывают локально и шлют готовый текст (новый путь). Тело — дословно из
|
||||
* SttStreamer (app-phone): числа выверены на дампах микрофона очков (×3, 16 кГц),
|
||||
* НЕ менять без перепроверки.
|
||||
*/
|
||||
|
||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||
fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||
val n = pcm.size / 2
|
||||
val out = FloatArray(n)
|
||||
var i = 0
|
||||
for (j in 0 until n) {
|
||||
val lo = pcm[i].toInt() and 0xff
|
||||
val hi = pcm[i + 1].toInt() and 0xff
|
||||
i += 2
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
out[j] = (s / 32768f).coerceIn(-1f, 1f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
|
||||
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
|
||||
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
|
||||
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
|
||||
*
|
||||
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
|
||||
* Чистая функция (без Android) — покрыта JVM-тестами.
|
||||
*/
|
||||
fun trimEdges(
|
||||
samples: FloatArray,
|
||||
threshold: Float = 0.01f,
|
||||
windowSize: Int = 320,
|
||||
padWindows: Int = 15,
|
||||
): FloatArray {
|
||||
if (samples.isEmpty()) return samples
|
||||
var first = -1
|
||||
var lastEnd = -1
|
||||
var idx = 0
|
||||
while (idx < samples.size) {
|
||||
val end = minOf(idx + windowSize, samples.size)
|
||||
var sum = 0.0
|
||||
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
|
||||
val rms = sqrt(sum / (end - idx)).toFloat()
|
||||
if (rms >= threshold) {
|
||||
if (first == -1) first = idx
|
||||
lastEnd = end
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
if (first == -1) return FloatArray(0)
|
||||
val pad = padWindows * windowSize
|
||||
val start = maxOf(0, first - pad)
|
||||
val stop = minOf(samples.size, lastEnd + pad)
|
||||
return samples.copyOfRange(start, stop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||
* НЕ менять без перепроверки на тех же дампах.
|
||||
*
|
||||
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
|
||||
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
|
||||
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
|
||||
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
|
||||
* каждой стороны (clamp к границам массива). Фраза короче
|
||||
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
|
||||
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
|
||||
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||
* < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
|
||||
*/
|
||||
fun splitPhrases(
|
||||
samples: FloatArray,
|
||||
threshold: Float = 0.005f,
|
||||
windowSize: Int = 320, // 20 мс @ 16 кГц
|
||||
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
||||
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
||||
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
||||
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
||||
): List<FloatArray> {
|
||||
if (samples.isEmpty()) return emptyList()
|
||||
val winCount = (samples.size + windowSize - 1) / windowSize
|
||||
val isSound = BooleanArray(winCount)
|
||||
var anySound = false
|
||||
for (w in 0 until winCount) {
|
||||
val s = w * windowSize
|
||||
val e = minOf(s + windowSize, samples.size)
|
||||
var sum = 0.0
|
||||
for (k in s until e) sum += samples[k] * samples[k].toDouble()
|
||||
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
|
||||
isSound[w] = true
|
||||
anySound = true
|
||||
}
|
||||
}
|
||||
if (!anySound) return emptyList()
|
||||
|
||||
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
|
||||
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
|
||||
class Phrase(var start: Int, var end: Int)
|
||||
|
||||
val phrases = mutableListOf<Phrase>()
|
||||
var pos = 0
|
||||
while (pos < winCount) {
|
||||
var sil = 0
|
||||
while (pos + sil < winCount && !isSound[pos + sil]) sil++
|
||||
if (pos + sil >= winCount) break
|
||||
val start = pos + sil
|
||||
var end = start
|
||||
var continuePhrase = true
|
||||
while (continuePhrase) {
|
||||
var run = 0
|
||||
while (end + run < winCount && isSound[end + run]) run++
|
||||
end += run
|
||||
if (end >= winCount) { continuePhrase = false; break }
|
||||
var sil2 = 0
|
||||
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
|
||||
if (sil2 >= minSilenceWindows) {
|
||||
continuePhrase = false
|
||||
} else {
|
||||
end += sil2
|
||||
}
|
||||
}
|
||||
phrases.add(Phrase(start, end))
|
||||
pos = end
|
||||
}
|
||||
|
||||
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
|
||||
while (phrases.size > 1) {
|
||||
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
|
||||
if (idx < 0) break
|
||||
val other = if (idx == 0) idx + 1 else idx - 1
|
||||
val lo = minOf(idx, other)
|
||||
val hi = maxOf(idx, other)
|
||||
val merged = Phrase(
|
||||
minOf(phrases[lo].start, phrases[hi].start),
|
||||
maxOf(phrases[lo].end, phrases[hi].end),
|
||||
)
|
||||
phrases.removeAt(hi)
|
||||
phrases.removeAt(lo)
|
||||
phrases.add(lo, merged)
|
||||
}
|
||||
|
||||
val pad = padWindows * windowSize
|
||||
val out = mutableListOf<FloatArray>()
|
||||
for (p in phrases) {
|
||||
val s = maxOf(0, p.start * windowSize - pad)
|
||||
val e = minOf(samples.size, p.end * windowSize + pad)
|
||||
if (e - s <= maxPhraseSamples) {
|
||||
out += samples.copyOfRange(s, e)
|
||||
} else {
|
||||
val chunk = (maxPhraseSamples / windowSize) * windowSize
|
||||
var cs = s
|
||||
while (cs < e) {
|
||||
val ce = minOf(e, cs + chunk)
|
||||
out += samples.copyOfRange(cs, ce)
|
||||
cs = ce
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -213,4 +213,14 @@ class GlassesToHostTest {
|
||||
assertIs<StopStt>(decoded)
|
||||
assertTrue(decoded.cancel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sttTextRoundTrip() {
|
||||
val msg: GlassesToHost = SttText(text = "включи сериал брат 2")
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
assertEquals("""{"type":"stt_text","text":"включи сериал брат 2"}""", text)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<SttText>(decoded)
|
||||
assertEquals("включи сериал брат 2", decoded.text)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user