Compare commits

7 Commits

Author SHA1 Message Date
subochev 79a500150d Пейринг RayNeo: кнопка ручного (без QR) ре-паринга + debug-роуты + keep-screen-on
- MercuryBridge.pairClassicAuto: классический discovery → найти RayNeo по
  имени → createBond (без QR и без камеры очков) для случая «бонд слетел,
  очки в first-pairing». discoverGlasses — список найденных.
- MercuryPanel: кнопка «🔗 паринг (вручную)» рядом с QR-парингом
- DebugHttpServer: /mercury/pair, /mercury/pair/stop, /mercury/pair/classic,
  /mercury/unpair, /mercury/bonded, /mercury/discover, поля pairing в /status
- keep-screen-on, чтобы QR/состояние реально были на экране телефона
2026-09-04 03:08:38 +03:00
subochev f68ea10342 Сборка release: подпись ключом Binom (v2+v3) + таск assembleSigned
signingConfigs в app-phone/app-glasses берут кестор и креды из скилла
android-sign (вне git); без кестора release собирается без подписи.
assembleSigned — собрать оба приложения одним шагом.
2026-09-04 03:08:28 +03:00
subochev 5c86ed00b4 STT: очки распознают речь локально и шлют готовый текст (SttText) в телефон
- glasses: локальный ASR через общий pw.binom.asr (GlassesAsr/GlassesSttSession/SttModelProvider)
- protocol: SttText — очки присылают распознанный текст в телефон; телефон кидает его в LLM
  (старый SttAudio/StopStt оставлен фолбэком для старых очей)
- phone: SttStreamer делегирует общему ASR; GlassesServer роутит SttText в LLM
- lib-core: AudioPrep общий
2026-09-04 03:08:26 +03:00
subochev 0b3f9ff975 Mirror: медленный list(500) не роняет каталог и не дёргается в цикле
- отдельный defaultMirrorHttpClient (connect 15с / request 90с) + 1 повтор на list():
  тяжёлый GET /api/mirror?limit=N на сервере дольше общего 30с-таймаута и подёргивается (503);
- каталог: зеркала грузятся в отдельном try — сбой/таймаут не чистит уже загруженный каталог,
  просто временно без бейджей;
- детали: убрали тяжёлый list(limit=500) каждые 3 с (progress зеркала на сервере не живой, 0
  до конца) — в цикле опрашивается только дешёвый mirrorByItem.
2026-09-02 23:02:56 +03:00
subochev 40723fa584 Каталог: массовые действия (конвертировать/скачать/удалить), фильтр «только скачанные» и BackHandler вложенных экранов 2026-09-02 22:38:54 +03:00
subochev 728116662e Добавляем WatchProgressStore (watchprogress.json) и его тесты 2026-09-02 22:38:30 +03:00
subochev 0cfd92a68f Добавляем экран «Активность»: что сейчас конвертируется и скачивается 2026-09-02 22:37:31 +03:00
37 changed files with 2022 additions and 214 deletions
+42
View File
@@ -1,3 +1,5 @@
import java.io.File
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,6 +7,20 @@ plugins {
alias(libs.plugins.compose.compiler)
}
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
val binomCertsDir: File = File(
(project.findProperty("binom.keystore.dir") as? String)
?: System.getenv("BINOM_KEYSTORE_DIR")
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
)
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
val binomCreds: Map<String, String> =
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
else emptyMap() })
android {
namespace = "pw.binom.viewmate.glasses"
compileSdk = 35
@@ -17,9 +33,30 @@ android {
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
}
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
// Без кестора binomCreds пусто → release остаётся неподписанным.
if (binomCreds.isNotEmpty()) {
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
signingConfigs {
create("binom") {
storeFile = binomStoreFile
storePassword = binomStorePass
keyAlias = binomAlias
keyPassword = binomKeyPass
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
release {
isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
}
}
@@ -61,6 +98,11 @@ dependencies {
implementation(project(":lib-core"))
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Локальный Qwen3-ASR на очках (движок + распознавание переезжают на очки из app-phone).
// asr-qwen3-android тянет sherpa-onnx (nativ) транзитивно; модель — в filesDir/models
// (SttModelProvider), как на телефоне.
implementation("pw.binom.asr:asr-qwen3-android:4")
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
@@ -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 }
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 {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
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(
@@ -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))
}
}
+39
View File
@@ -1,4 +1,5 @@
import java.util.Properties
import java.io.File
plugins {
alias(libs.plugins.android.application)
@@ -7,6 +8,20 @@ plugins {
alias(libs.plugins.compose.compiler)
}
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
val binomCertsDir: File = File(
(project.findProperty("binom.keystore.dir") as? String)
?: System.getenv("BINOM_KEYSTORE_DIR")
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
)
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
val binomCreds: Map<String, String> =
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
else emptyMap() })
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
val llmApiKey: String = run {
@@ -47,9 +62,30 @@ android {
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
}
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
// Без кестора binomCreds пусто → release остаётся неподписанным.
if (binomCreds.isNotEmpty()) {
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
signingConfigs {
create("binom") {
storeFile = binomStoreFile
storePassword = binomStorePass
keyAlias = binomAlias
keyPassword = binomKeyPass
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
release {
isMinifyEnabled = false
if (binomCreds.isNotEmpty()) {
signingConfig = signingConfigs.getByName("binom")
}
}
}
@@ -97,6 +133,9 @@ dependencies {
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
// QR-код для паринга очков (pairCode) — рендер BitMatrix->Bitmap
implementation("com.google.zxing:core:3.5.2")
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
implementation(libs.koog.openai.client)
implementation(libs.koog.http.client.okhttp)
@@ -121,9 +121,62 @@ object DebugHttpServer {
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
method == "GET" && path.startsWith("/mercury/status") -> {
val s = MercuryBridge.state.value
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"}}"""
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"},"pairing":${s.pairCode != null},"pairCode":${s.pairCode?.let { "\"$it\"" } ?: "null"},"realAddress":${s.realAddress?.let { "\"$it\"" } ?: "null"}}"""
code = 200
}
method == "GET" && path.startsWith("/mercury/bonded") -> {
val bonds = MercuryBridge.bondedAddresses()
val items = bonds.joinToString(",") { (addr, name) ->
"""{"address":"$addr","name":${name?.let { jsonEscape(it) } ?: "null"}}"""
}
response = """{"count":${bonds.size},"bonds":[$items]}"""
code = 200
}
method == "GET" && path.startsWith("/mercury/discover") -> {
val seconds = queryParams["time"]?.toIntOrNull() ?: 5
val found = MercuryBridge.discoverGlasses(seconds * 1_000L)
val items = found.joinToString(",") { (name, addr) ->
"""{"name":${name?.let { jsonEscape(it) } ?: "null"},"address":"$addr"}"""
}
val s = MercuryBridge.state.value
response = """{"count":${found.size},"found":[$items],"connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/pair/stop") -> {
MercuryBridge.cancelPairing()
val s = MercuryBridge.state.value
response = """{"ok":true,"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/pair/classic") -> {
val address = queryParams["address"]
if (address.isNullOrBlank()) {
response = """{"error":"нет параметра address"}"""
code = 400
} else {
val ok = MercuryBridge.pairClassic(address)
val s = MercuryBridge.state.value
response = """{"ok":$ok,"address":"$address","connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
}
method == "POST" && path.startsWith("/mercury/pair") -> {
val pairCode = MercuryBridge.startPairing()
val s = MercuryBridge.state.value
response = """{"ok":${pairCode != null},"pairCode":${pairCode?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/unpair") -> {
val address = queryParams["address"]
if (address.isNullOrBlank()) {
response = """{"error":"нет параметра address"}"""
code = 400
} else {
val ok = MercuryBridge.unpair(address)
response = """{"ok":$ok,"address":"$address"}"""
code = 200
}
}
method == "POST" && path.startsWith("/mercury/connect") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
@@ -150,6 +203,18 @@ object DebugHttpServer {
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/wifi") -> {
val enable = "true".equals(queryParams["enable"], ignoreCase = true)
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiToggle(enable) }
response = """{"ok":$ok,"enable":$enable,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/wifiscan") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiScan() }
val s = MercuryBridge.state.value
response = """{"ok":$ok,"networks":${s.wifiNetworks.size},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
else -> {
response = """{"error":"нет такого пути: $method $path"}"""
code = 404
@@ -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")
}
}
}
}
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
@@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestBtPermissions()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent {
ViewMateApp()
}
@@ -45,6 +45,10 @@ object MercuryBridge {
val wifiEnabled: Boolean = false,
val wifiSsid: String? = null,
val wifiNetworks: List<MyWifiInfo> = emptyList(),
/** Активный pairCode для QR-паринга (не null, пока очки не подключились). */
val pairCode: String? = null,
/** Реальный классический BT-адрес очков, полученный в pre-bond окне (null до паринга). */
val realAddress: String? = null,
)
private val _state = MutableStateFlow(UiState())
@@ -60,7 +64,7 @@ object MercuryBridge {
mercury!!.events.collect { event ->
when (event) {
is RayNeoMercury.Event.Connected -> {
_state.value = _state.value.copy(connected = true)
_state.value = _state.value.copy(connected = true, pairCode = null)
setStatus("подключено (BLE)")
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
// ползунок яркости и переключатель WiFi показывали реальные значения.
@@ -122,6 +126,28 @@ object MercuryBridge {
_state.value = _state.value.copy(wifiNetworks = event.networks)
log("wifi", "сетей: ${event.networks.size}")
}
is RayNeoMercury.Event.RealBluetoothAddress -> {
// Pre-bond handshake: очки сообщили свой реальный классический
// BT-адрес (он же ожидает QR-pairCode). Создаём OS-level bond —
// это и есть «паринг» (в стоке: PairStateMachine → AutoClassic → createBond).
log("pair", "real bt address: ${event.address} name=${event.name} type=${event.type}")
_state.value = _state.value.copy(realAddress = event.address)
setStatus("получен адрес ${event.address}, настраиваю связь (bond)…")
scope.launch {
val m = mercury
val res = if (m != null) m.pairByRealAddress(event.address)
else PairManager.PairingResult.Failed("нет связи с библиотекой")
when (res) {
is PairManager.PairingResult.Success -> {
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
setStatus("спаривание успешно: ${event.address}, подключаюсь…")
}
is PairManager.PairingResult.Failed -> {
setStatus("спаривание не удалось: ${res.reason}")
}
}
}
}
}
}
}
@@ -270,6 +296,96 @@ object MercuryBridge {
return ok
}
/**
* Старт паринга: перезапускает GATT-сервер с pairCode в рекламе и отдаёт код
* для QR. Пользователь показывает QR экрану очков — они его сканируют,
* подключаются в pre-bond окне и шлют свой реальный адрес
* (Event.RealBluetoothAddress), по которому мы делаем createBond.
*/
fun startPairing(): String? {
val m = mercury ?: return null
if (m.isAnyConnected) {
setStatus("очки уже подключены — паринг не нужен")
return null
}
val code = m.startPairing()
_state.value = _state.value.copy(pairCode = code)
setStatus("паринг: покажите QR экрану очков (код $code)")
return code
}
/** Отмена активного QR-паринга (прячет QR, перезапускает сервер в обычном режиме без pairCode). */
fun cancelPairing() {
_state.value = _state.value.copy(pairCode = null)
mercury?.stopPairing()
setStatus("паринг отменён")
}
/**
* Забывает очки (removeBond). Применяется, когда очки на своей стороне
* потеряли бонд, а телефон держит «старый» бонд — рассинхрон блокирует
* нормальное повторное паринг.
*/
fun unpair(address: String): Boolean {
val m = mercury ?: return false
return kotlinx.coroutines.runBlocking { m.unpairAddress(address) }
}
/** Текущие классические BT-бонды телефона (адрес → имя). Debug-помощник. */
fun bondedAddresses(): List<Pair<String, String?>> =
mercury?.bondedAddresses() ?: emptyList()
/**
* Классический паринг по известному классическому адресу — ручной путь без
* QR и камеры очков (то самое «подключите вручную в приложении» с экрана
* очков). createBond по адресу; очки после BONDED сами уходят из паринг-режима.
*/
fun pairClassic(address: String): Boolean {
val m = mercury ?: return false
val res = kotlinx.coroutines.runBlocking { m.pairByRealAddress(address) }
return if (res is PairManager.PairingResult.Success) {
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
setStatus("классический паринг успешен: $address (${res.device.name})")
true
} else {
setError("классический паринг не удался: ${(res as? PairManager.PairingResult.Failed)?.reason}")
false
}
}
/**
* Классический discovery: находит RayNeo-очки в режиме первого паринга вокруг
* телефона (без камеры). Возвращает список найденных (имя, адрес).
*/
fun discoverGlasses(timeoutMs: Long = 5_000): List<Pair<String, String?>> {
val m = mercury ?: return emptyList()
val found = kotlinx.coroutines.runBlocking { m.discoverClassic(timeoutMs) }
return found.map { it.device.name to it.address }
}
/**
* Ручной паринг «в один клик»: классический discovery, выбор RayNeo-очков и
* createBond. Кнопка вместо QR — для случая, когда очки снова в режиме первого
* паринга (слетел бонд) и камера очков недоступна/не считывает QR.
*/
fun pairClassicAuto(timeoutMs: Long = 7_000): String? {
val found = discoverGlasses(timeoutMs)
if (found.isEmpty()) {
setError("ручной паринг: рядом не найдено ни одного классического устройства — включите очки")
return null
}
val target = found.firstOrNull { (it.first)?.contains("rayneo", ignoreCase = true) == true }
if (target == null) {
setError("ручной паринг: RayNeo рядом не найден (проверьте, что очки включены и в режиме паринга)")
return null
}
val address = target.second
val ok = pairClassic(address ?: return null)
if (!ok) return null
setStatus("ручной паринг: очки подключены ($address)")
return address
}
private fun setStatus(text: String) {
log("status", text)
_state.value = _state.value.copy(statusText = text, lastError = null)
@@ -0,0 +1,116 @@
package pw.binom.viewmate.phone
import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** Прогресс просмотра элемента: как далеко досмотрели и длительность видео. */
@Serializable
data class WatchProgress(
val positionMs: Long,
/** Длительность видео, мс; -1 = неизвестно (старые очки не сообщали durationMs). */
val durationMs: Long = -1L,
) {
/** Процент просмотренного (0..100); null — длительность неизвестна. */
fun watchedPercent(): Int? =
if (durationMs <= 0L) null else ((positionMs.toDouble() / durationMs) * 100.0).toInt().coerceIn(0, 100)
}
@Serializable
internal data class WatchProgressFile(val entries: Map<String, WatchProgress>)
/**
* Прогресс просмотра по элементам: `filesDir/watchprogress.json`.
*
* Заполняется из PlaybackPosition очков (раз в 5с, только при продвижении вперёд),
* используется в двух местах: (1) возобновление просмотра с того же места по «▶ Смотреть»
* и (2) индикация просмотренного на карточках каталога.
* Ключ — itemId (не зависит от дорожки звука — позиция общая).
* Конструктор принимает директорию (в проде — filesDir) для JVM-тестируемости.
*/
class WatchProgressStore(dir: File) {
companion object {
/** Понять «новое начало»: просмотрено меньше 15 с — возобновляем с начала. */
const val MIN_RESUME_MS = 15_000L
/** 98% и больше — элемент досмотрен: в следующий раз начинаем сначала. */
const val FINISHED_RATIO = 0.98
}
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
private val file = File(dir, "watchprogress.json")
private val lock = Any()
private val entries = LinkedHashMap<String, WatchProgress>()
private val _flow = MutableStateFlow<Map<String, WatchProgress>>(emptyMap())
/** Снимки прогресса по itemId — для карточек каталога. */
val flow: StateFlow<Map<String, WatchProgress>> = _flow.asStateFlow()
init {
load()
_flow.value = entries.toMap()
}
/**
* Записать позицию элемента. На диск пишем только при продвижении вперёд
* или когда впервые известна длительность; дубликаты (пауза) — без записи.
* Позиция дальше длительности — мусор ExoPlayer (гашение экрана): игнор.
*/
fun upsert(itemId: String, positionMs: Long, durationMs: Long = -1L) {
if (positionMs < 0L) return
synchronized(lock) {
val old = entries[itemId] ?: WatchProgress(0L)
val knownDuration = if (durationMs > 0L) durationMs else old.durationMs
if (knownDuration > 0L && positionMs > knownDuration) {
log("progress", "упсерт отклонён: позиция $positionMs мс > длительность $knownDuration мс ($itemId)")
return
}
val advances = positionMs > old.positionMs ||
(durationMs > 0L && durationMs != old.durationMs)
if (!advances) return
entries[itemId] = WatchProgress(positionMs, if (durationMs > 0L) durationMs else old.durationMs)
persist()
_flow.value = entries.toMap()
}
}
/**
* Позиция для возобновления просмотра: null = начинать с начала
* (не смотрели, смотрели меньше [MIN_RESUME_MS] или досмотрели до [FINISHED_RATIO]).
*/
fun resumePosition(itemId: String): Long? {
val e = synchronized(lock) { entries[itemId] } ?: return null
return when {
e.positionMs < MIN_RESUME_MS -> null
e.durationMs > 0L && e.positionMs >= e.durationMs * FINISHED_RATIO -> null
else -> e.positionMs
}
}
/** Процент просмотренного (0..100); null — элемент не смотрели или длительность неизвестна. */
fun watchedPercent(itemId: String): Int? {
val e = synchronized(lock) { entries[itemId] } ?: return null
return e.watchedPercent()
}
private fun load() {
val text = runCatching { file.readText() }.getOrNull() ?: return
val parsed = runCatching { json.decodeFromString(WatchProgressFile.serializer(), text).entries }.getOrNull()
if (parsed != null) {
entries.putAll(parsed)
} else {
log("progress", "watchprogress.json не прочитан — начинаю пустым")
}
}
private fun persist() {
val result = runCatching { json.encodeToString(WatchProgressFile.serializer(), WatchProgressFile(entries.toMap())) }
result.onSuccess { text ->
runCatching { file.writeText(text) }.onFailure { log("progress", "запись watchprogress.json: ${it.message}") }
}.onFailure { log("progress", "сериализация watchprogress.json: ${it.message}") }
}
}
@@ -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 с по
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
* &lt; 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] (по клику) одним вызовом
@@ -0,0 +1,211 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.protocol.DownloadPhase as GlassesDownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress as GlassesDownloadProgress
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.DownloadPhase as PhoneDownloadPhase
import pw.binom.viewmate.phone.DownloadProgress as PhoneDownloadProgress
/** Статусы зеркала, которые считаются «конвертируется прямо сейчас». */
private val CONVERTING_STATUSES = setOf("new", "queued", "processing")
/** Как часто опрашиваем mirror.list, пока открыт экран (конвертация не шлёт события сама). */
private const val REFRESH_MS = 15_000L
/** Строка активности для единого списка в LazyColumn. */
private sealed interface ActivityRow {
val key: String
data class Header(val text: String, override val key: String) : ActivityRow
data class Convert(val itemId: String, val title: String, val statusLabel: String, override val key: String) : ActivityRow
data class Download(val itemId: String, val title: String, val kind: String, val percent: Int, override val key: String) : ActivityRow
}
/**
* Экран «Активность»: всё, что сейчас конвертируется (зеркала) и скачивается
* (звук → телефон, видео → очки). Уровень 1: без серверной доработки — по
* конвертации спиннер (progress на сервере не живой), по скачиванию живой %.
*/
@Composable
fun ActivityScreen() {
val app = PhoneApp.instance
val userId by app.userId.collectAsState()
// Конвертирующиеся зеркала: опрос mirror.list раз в REFRESH_MS (нет push от сервера).
var converting by remember { mutableStateOf<List<MirrorJob>>(emptyList()) }
LaunchedEffect(Unit) {
while (isActive) {
converting = runCatching {
app.mirror.list(limit = 500).filter { it.status in CONVERTING_STATUSES }
}.getOrDefault(converting)
delay(REFRESH_MS)
}
}
// Живой прогресс скачивания: звук (телефон) — DownloadManager, видео (очки) — хаб.
val phoneDownloads by app.downloadManager.downloads.collectAsState()
val activePhone by app.downloadManager.activeIds.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
// Настройки/названия предметов по активным itemId (подтягиваем только недостающие).
val activeIds = remember(converting, activePhone, glassesDownloads) {
(converting.mapNotNull { it.itemId } + activePhone + glassesDownloads.keys.filter { it.isNotBlank() })
.distinct()
}
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(activeIds, userId) {
if (userId.isBlank()) return@LaunchedEffect
activeIds.forEach { id ->
if (titles.containsKey(id)) return@forEach
val name = runCatching { app.jellyfin.item(userId, id)?.Name }.getOrNull()
titles = titles + (id to (name?.takeIf { it.isNotBlank() } ?: id))
}
}
val rows = buildActivityRows(
converting = converting,
phone = phoneDownloads,
activePhone = activePhone,
glasses = glassesDownloads,
titles = titles,
)
if (rows.isEmpty()) {
ScreenPlaceholder("Ничего не конвертируется и не скачивается")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
) {
items(rows) { row ->
ActivityRowView(row)
}
}
}
}
private fun buildActivityRows(
converting: List<MirrorJob>,
phone: Map<String, PhoneDownloadProgress>,
activePhone: Set<String>,
glasses: Map<String, Map<String, GlassesDownloadProgress>>,
titles: Map<String, String>,
): List<ActivityRow> {
val out = ArrayList<ActivityRow>()
val name = { id: String -> titles[id] ?: id }
val liveConverting = converting.filter { !it.itemId.isNullOrBlank() }
if (liveConverting.isNotEmpty()) {
out += ActivityRow.Header("Конвертируется", "h-conv")
liveConverting.forEach { job ->
val id = job.itemId.orEmpty()
val label = when (job.status) {
"processing" -> "обрабатывается"
"queued" -> "в очереди"
"new" -> "взято в работу"
else -> job.status ?: "…"
}
out += ActivityRow.Convert(id, name(id), label, "conv-${job.id}")
}
}
val phoneActive = phone.filterKeys { activePhone.contains(it) }
val glassesActive = glasses.filter { (itemId, files) ->
itemId.isNotBlank() && files.values.any { it.phase == GlassesDownloadPhase.DOWNLOADING }
}
if (phoneActive.isNotEmpty() || glassesActive.isNotEmpty()) {
out += ActivityRow.Header("Скачивается", "h-dl")
phoneActive.forEach { (id, p) ->
out += ActivityRow.Download(id, name(id), "звук", p.percent, "dl-$id-phone")
}
glassesActive.forEach { (id, files) ->
val video = files["video.mkv"] ?: files.values.firstOrNull()
out += ActivityRow.Download(id, name(id), "видео", video?.percent ?: 0, "dl-$id-glasses")
}
}
return out
}
@Composable
private fun ActivityRowView(row: ActivityRow) {
when (row) {
is ActivityRow.Header -> SectionLabel(
text = row.text,
modifier = Modifier.padding(top = 10.dp),
)
is ActivityRow.Convert -> StatusBlock {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
color = PhoneAccent,
strokeWidth = 2.dp,
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.title,
color = PhoneTextDim,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "зеркало: ${row.statusLabel}",
color = PhoneMuted,
fontSize = 10.sp,
)
}
}
}
is ActivityRow.Download -> StatusBlock {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = row.title,
modifier = Modifier.weight(1f),
color = PhoneTextDim,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${row.kind} ${row.percent}%",
color = PhoneMuted,
fontSize = 11.sp,
)
}
Spacer(Modifier.height(6.dp))
ProgressBar(percent = row.percent)
}
}
}
}
@@ -0,0 +1,211 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.PhoneConfig
import pw.binom.viewmate.phone.S3Presigner
import pw.binom.viewmate.phone.log
/** Статусы зеркала, при которых конвертацию заказывать не надо. */
private val MIRROR_BUSY_OR_DONE = setOf("done", "queued", "processing")
/**
* Массовые действия страницы сезона/сериала: все эпизоды отправить на конвертацию,
* скачать всё (звук → телефон, видео → очки) и удалить локальные файлы (телефон + очки).
*
* [episodes] — целевой набор эпизодов, [mirrors] — свежие статусы зеркал,
* [label] — «сезон»/«сериал» для подписи, [contextName] — название для диалога удаления.
*/
@Composable
fun BulkActionsRow(
episodes: List<JellyfinItem>,
mirrors: Map<String, MirrorJob>,
label: String,
contextName: String,
onMessage: (String) -> Unit,
onStateRefresh: () -> Unit,
) {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
val dm = app.downloadManager
val phoneDownloaded by dm.downloadedIds.collectAsState()
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
var busy by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) }
val statusOf: (JellyfinItem) -> String? = { mirrors[it.Id]?.status }
val needConversion = episodes.filter { statusOf(it) !in MIRROR_BUSY_OR_DONE }
val readyToDownload = episodes.filter { statusOf(it) == "done" }
/** Если всё уже конвертировано — кнопку «конвертировать» не рисуем. */
val convertable = needConversion.isNotEmpty()
/** Готовые зеркала, которые ещё не скачаны (телефон/очки); если все скачано — кнопку не рисуем. */
val toDownload = readyToDownload.filterNot { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
val withLocal = episodes.count { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
/** Массовая конвертация: заказ по каждому эпизоду без активного/готового зеркала. */
fun onConvert() {
if (needConversion.isEmpty() || busy) return
busy = true
scope.launch {
var ok = 0
var fail = 0
for (ep in needConversion) {
runCatching { app.mirror.createMirror(ep.Id) }
.onSuccess { ok++ }
.onFailure {
fail++
log("bulk", "mirror ${ep.Id}: ${it.message}")
}
}
busy = false
onStateRefresh()
onMessage(
buildString {
append("Конвертация: заказано $ok")
if (fail > 0) append(", ошибок $fail")
append(" ($label)")
},
)
}
}
/**
* Массовое скачивание готовых зеркал: звук на телефон (DownloadManager),
* видео на очки (DownloadFiles через actions). Очки не подключены — только звук на телефон.
*/
fun onDownload() {
if (toDownload.isEmpty() || busy) return
busy = true
scope.launch {
val actions = app.actions
val glassesOk = app.server.hub.connected.value > 0 && actions != null
var videoSent = 0
for (ep in toDownload) {
dm.startDownload(ep.Id)
if (glassesOk) {
val result = runCatching {
actions.downloadToGlasses(ep.Id, sizes = dm.knownFileSizes(ep.Id)) {
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
}
if (result.isFailure) log("bulk", "скачивание на очки ${ep.Id}: ${result.exceptionOrNull()?.message}")
else videoSent++
}
}
busy = false
onStateRefresh()
onMessage(
buildString {
append("Скачивание: звук → телефон, видео → очки ($videoSent) ($label)")
if (!glassesOk) append(" [очки не подключены — только телефон]")
},
)
}
}
/** Удаление локальных файлов всех эпизодов: диск телефона + каталог контента очков. */
fun onDelete() {
if (withLocal == 0 || busy) return
busy = true
scope.launch {
var deleted = 0
for (ep in episodes) {
if (ep.Id !in phoneDownloaded && ep.Id !in glassesDownloadedIds) continue
dm.delete(ep.Id)
app.actions?.deleteDownloaded(ep.Id)
deleted++
}
busy = false
onStateRefresh()
onMessage("Удалено локальное: $deleted эпизод(ов) ($label)")
}
}
if (convertable || toDownload.isNotEmpty() || withLocal > 0) {
Text(
text = "все эпизоды $label",
color = PhoneMuted,
fontSize = 10.sp,
modifier = Modifier.padding(start = 14.dp, top = 4.dp, end = 14.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (convertable) {
OutlinedButton(onClick = ::onConvert, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "конвертировать",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (toDownload.isNotEmpty()) {
OutlinedButton(onClick = ::onDownload, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "скачать",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (withLocal > 0) {
OutlinedButton(onClick = { confirmDelete = true }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(
text = "удалить локальное ($withLocal)",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Удалить локальные файлы?") },
text = {
Text(
"«$contextName»: с телефонов и очков удалятся уже скачанные " +
"видео/звук по ${withLocal} эпизод(ам). Это действие необратимо.",
fontSize = 12.sp,
)
},
confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Удалить") }
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
},
)
}
}
@@ -1,5 +1,6 @@
package pw.binom.viewmate.phone.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -17,7 +18,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -27,6 +31,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -47,6 +52,7 @@ import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.WatchProgress
import pw.binom.viewmate.phone.log
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
@@ -86,6 +92,8 @@ class CatalogState(
private set
var scrollOffset by mutableStateOf(0)
private set
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
var onlyDownloaded by mutableStateOf(false)
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
@@ -198,7 +206,6 @@ class CatalogState(
}
}
items = loaded
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -207,6 +214,15 @@ class CatalogState(
} finally {
loading = false
}
// Зеркала — отдельный «мягкий» запрос: сбой/таймаут mirror.list не чистит
// уже загруженный каталог, просто временно без бейджей.
try {
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("catalog", "зеркала: не удалось (${e.message}) — каталог без бейджей")
}
}
}
@@ -233,6 +249,36 @@ fun CatalogScreen(
) {
val app = PhoneApp.instance
val userId by app.userId.collectAsState()
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
// Системная кнопка «назад»: выход из вложенного пути (сериал→сезон, папки) на уровень выше
BackHandler(enabled = state.canGoBack()) {
state.back()
}
// Все эпизоды сериала (для bulk-операций): по каждому сезону — список эпизодов.
var seriesEpisodes by remember { mutableStateOf<List<JellyfinItem>?>(null) }
val seasonIds = state.items.map { it.Id }
LaunchedEffect(state.path, seasonIds, userId) {
if (state.path !is CatalogState.Path.Series || userId.isBlank() || state.items.isEmpty()) {
seriesEpisodes = emptyList()
return@LaunchedEffect
}
seriesEpisodes = state.items.flatMap { season ->
runCatching { app.jellyfin.items(userId, season.Id, listOf("Episode")) }.getOrDefault(emptyList())
}
}
val bulkEpisodes = when (state.path) {
is CatalogState.Path.Season -> state.items
is CatalogState.Path.Series -> seriesEpisodes ?: emptyList()
else -> emptyList()
}
val bulkLabel = when (state.path) {
is CatalogState.Path.Season -> "сезона"
is CatalogState.Path.Series -> "сериала"
else -> ""
}
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
val gridState = rememberLazyGridState()
@@ -264,12 +310,40 @@ fun CatalogScreen(
Column(modifier = Modifier.fillMaxSize()) {
CatalogTopBar(state = state)
if (bulkEpisodes.isNotEmpty()) {
BulkActionsRow(
episodes = bulkEpisodes,
mirrors = state.mirrors,
label = bulkLabel,
contextName = state.subtitle(),
onMessage = { message -> scope.launch { snackbar.showSnackbar(message) } },
onStateRefresh = state::refresh,
)
}
// фильтр «только скачанные (и частично)» — остаётся только то, что скачано на каком-то устройстве
val visibleItems: List<JellyfinItem> = if (state.onlyDownloaded) {
state.items.filter { item ->
val id = item.Id
downloaded.containsKey(id) ||
localDownloaded.contains(id) ||
glassesDownloadedIds.contains(id) ||
glassesDownloads.containsKey(id)
}
} else {
state.items
}
if (state.loading && state.items.isEmpty()) {
ScreenPlaceholder("загружаю…")
} else if (state.error != null && state.items.isEmpty()) {
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
} else if (state.items.isEmpty()) {
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
} else if (visibleItems.isEmpty()) {
ScreenPlaceholder(
when {
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
state.query.isNotBlank() -> "ничего не нашлось"
else -> "пусто"
},
)
} else {
LazyVerticalGrid(
state = gridState,
@@ -279,7 +353,7 @@ fun CatalogScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.items, key = { it.Id }) { item ->
items(visibleItems, key = { it.Id }) { item ->
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
ItemCard(
item = item,
@@ -317,6 +391,7 @@ fun CatalogScreen(
}
}
}
SnackbarHost(hostState = snackbar)
}
}
@@ -373,6 +448,21 @@ private fun CatalogTopBar(state: CatalogState) {
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = state.onlyDownloaded,
onCheckedChange = { state.onlyDownloaded = it },
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = "только скачанные (и частично)",
color = PhoneMuted,
fontSize = 11.sp,
)
}
}
}
@@ -37,6 +37,7 @@ import java.util.Locale
enum class Screen {
CATALOG,
DETAILS,
ACTIVITY,
GLASSES,
GLASSES_INFO,
CHAT,
@@ -46,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
private val bottomTabs = listOf(
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
BottomTab(Screen.CHAT, "💬", "Чат"),
)
@@ -307,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
}
@Composable
fun SectionLabel(text: String) {
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
color = PhoneMuted,
@@ -315,6 +317,6 @@ fun SectionLabel(text: String) {
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 6.dp),
modifier = modifier.padding(bottom = 6.dp),
)
}
@@ -78,16 +78,16 @@ fun DetailsScreen(
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
// статус зеркала + дорожки; пока конвертируется — опрашиваем
// статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
LaunchedEffect(item.Id, refreshKey) {
loading = true
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
loading = false
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
delay(3_000)
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
}
}
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
@@ -112,6 +113,66 @@ fun MercuryPanel() {
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { MercuryBridge.startPairing() },
enabled = !state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneAccent,
contentColor = Color(0xFF10251A),
),
) {
Text("🔗 паринг (QR)", fontSize = 12.sp)
}
if (state.pairCode != null) {
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { MercuryBridge.cancelPairing() },
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text("отмена", fontSize = 12.sp)
}
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.pairClassicAuto() } },
enabled = !state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF3A3F47),
contentColor = Color(0xFFD5DBDF),
),
) {
Text("🔗 паринг (вручную)", fontSize = 12.sp)
}
}
val pairCode = state.pairCode
if (pairCode != null) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Паринг: отсканируйте QR картинку камерой очков (она должна смотреть на экран телефона)",
color = PhoneMuted,
fontSize = 11.sp,
)
QrCode(pairCode, side = 280, modifier = Modifier.padding(top = 8.dp))
Spacer(modifier = Modifier.height(6.dp))
Text(
"Код: $pairCode",
color = PhoneText,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.screenOff() } },
@@ -0,0 +1,58 @@
package pw.binom.viewmate.phone.ui
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
/**
* Рендер QR-кода (pairCode) для паринга очков: очки сканируют его своей камерой.
* В стоковом приложении RayNeo QR кодирует ровно pairCode (8 символов).
*/
@Composable
fun QrCode(
text: String,
modifier: Modifier = Modifier,
side: Int = 200,
foreground: Color = Color.Black,
background: Color = Color.White,
) {
val density = LocalDensity.current.density
val sizePx = (side * density).toInt().coerceAtLeast(256)
val bitmap = remember(text, sizePx, foreground, background) {
renderQr(
text = text,
sizePx = sizePx,
foreground = foreground.toArgb(),
background = background.toArgb(),
)
}
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-код паринга",
modifier = modifier.size(side.dp),
)
}
private fun renderQr(text: String, sizePx: Int, foreground: Int, background: Int): Bitmap {
val bits = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx)
val w = bits.width
val h = bits.height
val pixels = IntArray(w * h)
for (y in 0 until h) {
val rowOffset = y * w
for (x in 0 until w) {
pixels[rowOffset + x] = if (bits.get(x, y)) foreground else background
}
}
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
}
@@ -51,6 +51,8 @@ fun ViewMateApp() {
},
)
Screen.ACTIVITY -> ActivityScreen()
Screen.DETAILS -> {
val item = selected
if (item != null) {
@@ -0,0 +1,95 @@
package pw.binom.viewmate.phone
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class WatchProgressStoreTest {
private fun tempDir(name: String): File = createTempDirectory(name).toFile()
private fun newStore(): Pair<WatchProgressStore, File> {
val dir = tempDir("watch-progress")
return WatchProgressStore(dir) to dir
}
@Test
fun `upsert and resume position`() {
val (store, _) = newStore()
store.upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
assertEquals(3_600_000L, store.resumePosition("item-1"))
assertEquals(50, store.watchedPercent("item-1"))
assertNull(store.resumePosition("other"))
}
@Test
fun `resume returns null for short or finished viewing`() {
val (store, _) = newStore()
store.upsert("short", positionMs = 10_000L, durationMs = 7_200_000L)
assertNull(store.resumePosition("short"))
val finishedMs = (7_200_000L * WatchProgressStore.FINISHED_RATIO).toLong() + 1000L
store.upsert("finished", positionMs = finishedMs, durationMs = 7_200_000L)
assertNull(store.resumePosition("finished"))
val almost = 7_200_000L * 0.90
store.upsert("almost", positionMs = almost.toLong(), durationMs = 7_200_000L)
assertNotNull(store.resumePosition("almost"))
}
@Test
fun `backward and duplicate positions are not written`() {
val (store, dir) = newStore()
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
val writtenAfterAdvance = File(dir, "watchprogress.json").readText()
store.upsert("item-1", positionMs = 55_000L, durationMs = 7_200_000L)
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
assertEquals(writtenAfterAdvance, File(dir, "watchprogress.json").readText())
}
@Test
fun `position beyond duration is rejected`() {
val (store, dir) = newStore()
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
val before = File(dir, "watchprogress.json").readText()
store.upsert("item-1", positionMs = 99_000_000L, durationMs = 7_200_000L)
assertEquals(before, File(dir, "watchprogress.json").readText())
assertEquals(60_000L, store.resumePosition("item-1"))
}
@Test
fun `duration discovered later is persisted`() {
val (store, dir) = newStore()
store.upsert("item-1", positionMs = 60_000L)
assertNull(store.watchedPercent("item-1"))
store.upsert("item-1", positionMs = 60_000L, durationMs = 120_000L)
assertEquals(50, store.watchedPercent("item-1"))
assertEquals("watchprogress.json", File(dir, "watchprogress.json").name)
}
@Test
fun `progress survives reload`() {
val dir = tempDir("watch-progress-reload")
WatchProgressStore(dir).upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
val reloaded = WatchProgressStore(dir)
assertEquals(3_600_000L, reloaded.resumePosition("item-1"))
assertEquals(50, reloaded.watchedPercent("item-1"))
assertEquals(mapOf("item-1" to WatchProgress(3_600_000L, 7_200_000L)), reloaded.flow.value)
}
@Test
fun `unknown duration gives percent null but resume works`() {
val (store, _) = newStore()
store.upsert("item-1", positionMs = 3_600_000L)
assertNull(store.watchedPercent("item-1"))
assertEquals(3_600_000L, store.resumePosition("item-1"))
}
}
@@ -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): краевая тишина убирается,
+10
View File
@@ -14,3 +14,13 @@ allprojects {
}
group = "pw.binom.viewmate"
}
// Собери оба приложения и подпиши ключом Binom (v2+v3) одним шагом.
// Кестор/креды берутся из скилла android-sign (см. signingConfigs в build.gradle.kts каждого app-модуля);
// без кестора release соберётся без подписи.
tasks.register("assembleSigned") {
group = "build"
description = "Собирает app-phone и app-glasses release-APK, подписанные ключом Binom (v2+v3)."
dependsOn(":app-phone:assembleRelease", ":app-glasses:assembleRelease")
}
@@ -42,6 +42,35 @@ fun defaultHttpClient(): HttpClient = HttpClient {
}
}
/**
* HTTP-клиент для media-mirror: тяжёлые опросы (mirror.list с большим лимитом)
* на сервере считаются заметно дольше, чем обычные запросы, поэтому таймауты
* шире [defaultHttpClient] — иначе живой, но медленный ответ упирается в таймаут.
*/
fun defaultMirrorHttpClient(): HttpClient = HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
)
}
install(HttpTimeout) {
connectTimeoutMillis = 15_000
requestTimeoutMillis = 90_000
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
}
}
level = LogLevel.INFO
}
}
/** Флаг debug-логирования HTTP (по умолчанию включён). */
object HttpClientDebugLogging {
var enabled: Boolean = true
@@ -2,6 +2,8 @@ package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
@@ -19,7 +21,7 @@ import io.ktor.http.contentType
class MirrorClient(
private val baseUrl: String, // https://mirror.binom.pw
private val apiKey: String, // X-API-Key
private val http: HttpClient = defaultHttpClient(),
private val http: HttpClient = defaultMirrorHttpClient(),
) {
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
suspend fun createMirror(itemId: String): MirrorJob {
@@ -42,8 +44,11 @@ class MirrorClient(
return response.body()
}
/** Список зеркал: статус (null — все) + лимит. */
/** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
var last: Exception? = null
repeat(LIST_ATTEMPTS) { attempt ->
try {
val response = http.get("$baseUrl/api/mirror") {
header("X-API-Key", apiKey)
parameter("limit", limit)
@@ -51,6 +56,14 @@ class MirrorClient(
}
response.ensureSuccess("mirror.list")
return response.body()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
last = e
if (attempt < LIST_ATTEMPTS - 1) delay(LIST_RETRY_DELAY_MS)
}
}
throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
}
/** Удаление зеркала (204). */
@@ -69,4 +82,11 @@ class MirrorClient(
response.ensureSuccess("mirror.scan")
return response.body()
}
companion object {
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
private const val LIST_ATTEMPTS = 2
private const val LIST_RETRY_DELAY_MS = 1_500L
}
}
@@ -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 с по
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
* &lt; 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)
}
}