feat: управление яркостью/WiFi очков через Mercury + накопленные доработки

- Mercury (app-phone): управление яркостью (с авто-выкл. перед
  применением) и WiFi (скан/подключение/список сетей с паролем);
  добавлено право BLUETOOTH_ADVERTISE в манифест и runtime-запрос
  (без него GATT-сервер не рекламируется → очки не подключаются)
- логирование (lib-core core.log): BatchingLogCollector + LogSink +
  LogSpool + LokiLogSink; цепочка очки→телефон→Loki; lib-core log()
  переадресован в коллектор
- STT: Qwen3-ASR через pw.binom.asr:asr-qwen3-android, убран in-house
  whisper-фоллбек
- audio-sync: P-регулятор с насыщением вместо фиксированной скорости
- media-split: очки только video, телефон только audio (UI-чипы)
- Components: форматирование размера через Locale.ROOT
This commit is contained in:
2026-08-29 23:06:52 +03:00
parent 62d2f06797
commit 6cd4a8de43
40 changed files with 1409 additions and 203 deletions
@@ -31,8 +31,14 @@ internal data class SyncDecision(
/** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
internal const val TRIGGER_BAND_MS = 150L
/** Темп догонки/отстаивания за пределами гистерезиса: 1.02 / 0.98 на каждый цикл. */
internal const val DRIFT_RATE = 0.02f
/**
* Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f).
* 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED].
*/
internal const val DRIFT_GAIN = 0.0002f
/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */
internal const val MAX_DRIFT_SPEED = 0.10f
/** Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo. */
internal const val HARD_SEEK_THRESHOLD_MS = 2000L
@@ -40,8 +46,9 @@ internal const val HARD_SEEK_THRESHOLD_MS = 2000L
/**
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
* - audio отстаёт от video (diff < -band) → speed = 1 + [DRIFT_RATE] (нагоняем);
* - audio впереди video (diff > +band) → speed = 1 - [DRIFT_RATE] (тормозим);
* - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем),
* звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается
* [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно;
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
*/
@@ -55,9 +62,8 @@ internal fun decideSync(
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
val speed = when {
seekToMs != null -> 1f
diff < -TRIGGER_BAND_MS -> 1f + DRIFT_RATE
diff > TRIGGER_BAND_MS -> 1f - DRIFT_RATE
else -> 1f
abs(diff) <= TRIGGER_BAND_MS -> 1f
else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED)
}
val playing = when {
videoPlaying == audioPlaying -> null
@@ -44,12 +44,17 @@ class BtServerTransport(
/**
* Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
* 31+ — BLUETOOTH_CONNECT (SCAN — для сканирования; ADVERTISE — для GATT-сервера
* очков, Mercury-управление),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
arrayOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE,
)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.log.LogEntry
import pw.binom.viewmate.core.net.GlassesServerTransport
import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState
@@ -22,6 +23,7 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.LogBatchMsg
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
@@ -97,6 +99,13 @@ class GlassesHub(
*/
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null
/**
* Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
* (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
*/
@Volatile
var onLogBatch: ((List<LogEntry>) -> Unit)? = null
/**
* Стриминговое распознавание речи с очков. Лениво создаётся через
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
@@ -195,6 +204,11 @@ class GlassesHub(
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
}
is LogBatchMsg -> {
log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
onLogBatch?.invoke(msg.entries)
}
is SttAudio -> {
val s = stt ?: sttFactory?.invoke()?.also { stt = it }
s?.accept(msg.data)
@@ -1,13 +1,25 @@
package pw.binom.viewmate.phone
import android.util.Log
import pw.binom.viewmate.core.log.BatchingLogCollector
/**
* Единый лог телефона.
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
* (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
* (android.util.Log недоступен — метод без @JvmStatic фолбэктнет в println),
* чтобы не падать.
*
* Плюс лог-коллектор: пачки (свои + пришедшие от очков) уходят на сервер
* (HttpLogSink); при сбое — прихраняются в споуле и ретраятся позже.
*/
@Volatile
private var logCollector: BatchingLogCollector? = null
/** Подключить коллектор (PhoneApp.onCreate). */
fun attachLogCollector(collector: BatchingLogCollector) {
logCollector = collector
}
fun log(tag: String, message: String) {
val full = "[view-mate] $tag $message"
runCatching {
@@ -15,4 +27,5 @@ fun log(tag: String, message: String) {
}.onFailure {
println(full)
}
logCollector?.record(tag, message)
}
@@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury
import pw.binom.mercury.model.MyWifiInfo
import pw.binom.mercury.pair.PairManager
/**
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
@@ -38,6 +40,11 @@ object MercuryBridge {
val glassesName: String? = null,
val statusText: String = "не подключено",
val lastError: String? = null,
val brightness: Int = 0,
val automaticBrightness: Boolean = false,
val wifiEnabled: Boolean = false,
val wifiSsid: String? = null,
val wifiNetworks: List<MyWifiInfo> = emptyList(),
)
private val _state = MutableStateFlow(UiState())
@@ -55,6 +62,9 @@ object MercuryBridge {
is RayNeoMercury.Event.Connected -> {
_state.value = _state.value.copy(connected = true)
setStatus("подключено (BLE)")
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
// ползунок яркости и переключатель WiFi показывали реальные значения.
scope.launch { mercury?.pullGeneralStatus() }
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
// при гашении экрана очки рвут BLE-связь через ~3 секунды.
// Повторяем, пока очки не будут готовы (они переподключаются
@@ -94,8 +104,20 @@ object MercuryBridge {
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}")
is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}")
is RayNeoMercury.Event.GeneralStatusUpdate -> {
val s = event.status
_state.value = _state.value.copy(
brightness = s.brightness,
automaticBrightness = s.automaticBrightness,
wifiEnabled = s.wifiEnabled,
wifiSsid = s.wifiInfo?.ssid,
)
log("status", "brightness=${s.brightness} autoBrightness=${s.automaticBrightness} deepSuspend=${s.deepSuspend} wifi=${s.wifiEnabled} ssid=${s.wifiInfo?.ssid}")
}
is RayNeoMercury.Event.WifiScanResult -> {
_state.value = _state.value.copy(wifiNetworks = event.networks)
log("wifi", "сетей: ${event.networks.size}")
}
}
}
}
@@ -109,7 +131,8 @@ object MercuryBridge {
val m = mercury ?: return false
return m.hasPermissions() &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED
}
/**
@@ -141,6 +164,20 @@ object MercuryBridge {
setStatus("подключено (клиент)")
return true
}
// Без bond очки не отдают GATT-сервис — спариваем и пробуем ещё раз.
setStatus("connect не удался, спариваю…")
when (val res = m.pair(glasses)) {
is PairManager.PairingResult.Success -> {
if (m.connect(glasses, 8000)) {
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
setStatus("подключено (клиент, после pair)")
return true
}
}
is PairManager.PairingResult.Failed -> {
setStatus("спаривание не удалось: ${res.reason}")
}
}
}
}
setError("очки не подключились за ${timeoutMillis / 1000} с")
@@ -174,13 +211,68 @@ object MercuryBridge {
return ok
}
/** Яркость экрана очков (0..100). Авто-яркость принудительно выключается — иначе очки игнорируют ручное значение. */
suspend fun setBrightness(value: Int): Boolean {
val v = value.coerceIn(0, 100)
// При automaticBrightness=true очки отбрасывают brightness, поэтому выключаем авто заранее.
mercury?.setAutoBrightness(false)
val ok = mercury?.setBrightness(v) ?: false
if (ok) _state.value = _state.value.copy(brightness = v, automaticBrightness = false)
setStatus(if (ok) "яркость → $v%" else "ошибка отправки (нет связи?)")
return ok
}
/** Авто-яркость очков. */
suspend fun setAutoBrightness(enable: Boolean): Boolean {
val ok = mercury?.setAutoBrightness(enable) ?: false
if (ok) _state.value = _state.value.copy(automaticBrightness = enable)
setStatus(if (ok) "авто-яркость → $enable" else "ошибка отправки (нет связи?)")
return ok
}
/** Вкл/выкл WiFi-радио очков. */
suspend fun wifiToggle(enable: Boolean): Boolean {
val ok = mercury?.wifiToggle(enable) ?: false
if (ok) _state.value = _state.value.copy(wifiEnabled = enable)
setStatus(if (ok) "WiFi → ${if (enable) "вкл" else "выкл"}" else "ошибка отправки (нет связи?)")
return ok
}
/** Подключить очки к WiFi-сети (пароль null, если открытая). */
suspend fun wifiConnect(ssid: String, password: String?): Boolean {
val ok = mercury?.wifiConnect(ssid, password) ?: false
setStatus(if (ok) "WiFi: подключение к «$ssid»…" else "ошибка отправки (нет связи?)")
return ok
}
/** Забыть сохранённую WiFi-сеть на очках. */
suspend fun wifiRemove(ssid: String): Boolean {
val ok = mercury?.wifiRemove(ssid) ?: false
setStatus(if (ok) "WiFi: забыть «$ssid»" else "ошибка отправки (нет связи?)")
return ok
}
/** Сканировать доступные WiFi-сети (результат → state.wifiNetworks). */
suspend fun wifiScan(): Boolean {
val ok = mercury?.wifiScan() ?: false
setStatus(if (ok) "WiFi: сканирование…" else "ошибка отправки (нет связи?)")
return ok
}
/** Запросить актуальный статус очков (яркость/WiFi/батарея). */
suspend fun pullStatus(): Boolean {
val ok = mercury?.pullGeneralStatus() ?: false
if (!ok) setStatus("ошибка запроса статуса (нет связи?)")
return ok
}
private fun setStatus(text: String) {
log("status", text)
_state.value = _state.value.copy(statusText = text, lastError = null)
}
private fun setError(text: String) {
Log.e(TAG, text)
log("mercury", text)
_state.value = _state.value.copy(statusText = text, lastError = text)
}
@@ -193,6 +285,6 @@ object MercuryBridge {
}
private fun log(tag: String, msg: String) {
Log.d(TAG, "[$tag] $msg")
pw.binom.viewmate.phone.log(tag, msg)
}
}
@@ -17,6 +17,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.log.BatchingLogCollector
import pw.binom.viewmate.core.log.FileLogSpool
import pw.binom.viewmate.core.log.LokiConfig
import pw.binom.viewmate.core.log.SystemClock
import pw.binom.viewmate.core.log.lokiLogSink
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions
@@ -49,7 +54,6 @@ import pw.binom.viewmate.phone.agent.ToolsetRegistry
import pw.binom.viewmate.phone.stt.PhraseRecognizer
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
/**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
@@ -84,6 +88,10 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
lateinit var logCollector: BatchingLogCollector
private set
/** История общения с ассистентом (SQLite, "assistant.db"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
@@ -345,9 +353,9 @@ class PhoneApp : Application() {
}
/**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
* на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
*/
private fun ensureStt(): SttStreamer? {
synchronized(sttLock) {
@@ -357,39 +365,24 @@ class PhoneApp : Application() {
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
val qwenDec = File(modelsDir, "decoder.int8.onnx")
val qwenTok = File(modelsDir, "tokenizer")
var stt: PhraseRecognizer
if (STT_BACKEND == "qwen3_asr" &&
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
) {
stt = Qwen3AsrStt(
convFrontendPath = qwenConv.absolutePath,
encoderPath = qwenEnc.absolutePath,
decoderPath = qwenDec.absolutePath,
tokenizerDir = qwenTok.absolutePath,
numThreads = 4,
hotwords = STT_HOTWORDS,
)
} else {
val encoder = File(modelsDir, "small-encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt")
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null
}
stt = WhisperStt(
encoderPath = encoder.absolutePath,
decoderPath = decoder.absolutePath,
tokensPath = tokens.absolutePath,
numThreads = 4,
)
if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null
}
val stt: PhraseRecognizer = Qwen3AsrStt(
convFrontendPath = qwenConv.absolutePath,
encoderPath = qwenEnc.absolutePath,
decoderPath = qwenDec.absolutePath,
tokenizerDir = qwenTok.absolutePath,
numThreads = 4,
hotwords = STT_HOTWORDS,
)
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw")
} else {
null
}
log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)")
val s = SttStreamer(
stt = stt,
onSilence30s = {
@@ -406,7 +399,7 @@ class PhoneApp : Application() {
)
sttStreamer = s
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов ($STT_BACKEND)")
log("stt", "SttStreamer готов (qwen3_asr)")
return s
}
}
@@ -452,6 +445,27 @@ class PhoneApp : Application() {
nsd.publish()
server.hub.sttFactory = { ensureStt() }
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
// Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki
// выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool.
logCollector = BatchingLogCollector(
device = "phone",
sink = lokiLogSink(),
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64),
clock = SystemClock(),
flushIntervalMs = 60_000,
maxBatchEntries = 500,
)
logCollector.start(scope)
attachLogCollector(logCollector)
pw.binom.viewmate.core.logCollector = logCollector
server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) }
log(
"server",
"лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " +
"спул: filesDir/logspool)",
)
scope.launch { assistantLoop() }
val lastPositionAt = AtomicLong(0)
@@ -534,9 +548,6 @@ class PhoneApp : Application() {
lateinit var instance: PhoneApp
private set
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
private const val STT_BACKEND = "qwen3_asr"
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
}
@@ -1,51 +1,29 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
import pw.binom.asr.qwen3.Qwen3Config
import pw.binom.asr.qwen3.createQwen3Sherpa
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
/** Офлайн-распознавание: asr-qwen3 (sherpa-onnx + Qwen3-ASR-0.6B int8, CPU). */
class Qwen3AsrStt(
private val convFrontendPath: String,
private val encoderPath: String,
private val decoderPath: String,
private val tokenizerDir: String,
private val numThreads: Int = 4,
private val hotwords: String = "",
convFrontendPath: String,
encoderPath: String,
decoderPath: String,
tokenizerDir: String,
numThreads: Int = 4,
hotwords: String = "",
) : PhraseRecognizer {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
qwen3Asr = OfflineQwen3AsrModelConfig(
convFrontend = convFrontendPath,
encoder = encoderPath,
decoder = decoderPath,
tokenizer = tokenizerDir,
maxTotalLen = 512,
maxNewTokens = 128,
temperature = 1e-6f,
topP = 0.8f,
seed = 42,
hotwords = hotwords,
),
numThreads = numThreads,
provider = "cpu",
modelType = "qwen3_asr",
),
/** Тяжёлая нативка создаётся один раз; recognize() — single-shot (стрим на вызов). */
private val recognizer = createQwen3Sherpa(
Qwen3Config(
convFrontendPath = convFrontendPath,
encoderPath = encoderPath,
decoderPath = decoderPath,
tokenizerDir = tokenizerDir,
numThreads = numThreads,
hotwords = hotwords,
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}
override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000)
}
@@ -1,15 +1,12 @@
package pw.binom.viewmate.phone.stt
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileInputStream
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
object SttDebug {
private const val TAG = "stt"
/**
* Распознаёт [wavPath] по фразам (VAD + Whisper).
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
@@ -54,14 +51,14 @@ object SttDebug {
log("[stt] ВЕСЬ ТЕКСТ: $all")
all
} catch (t: Throwable) {
Log.e(TAG, "[stt] ошибка: ${t.message}", t)
log("[stt] ошибка: ${t.message}")
t.printStackTrace()
null
}
}
private fun log(msg: String) {
Log.i(TAG, msg)
pw.binom.viewmate.phone.log("stt", msg)
println(msg)
}
}
@@ -1,46 +0,0 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig
/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */
class WhisperStt(
private val encoderPath: String,
private val decoderPath: String,
private val tokensPath: String,
private val numThreads: Int = 4,
private val language: String = "ru",
) : PhraseRecognizer {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
whisper = OfflineWhisperModelConfig(
encoder = encoderPath,
decoder = decoderPath,
language = language,
task = "transcribe",
tailPaddings = -1,
),
tokens = tokensPath,
numThreads = numThreads,
provider = "cpu",
modelType = "whisper",
),
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
override fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import java.util.Locale
/** Экраны приложения (простая навигация без nav-библиотеки). */
enum class Screen {
@@ -168,7 +169,7 @@ fun bytesToSize(bytes: Long?): String {
val gb = 1024.0 * 1024 * 1024
val mb = 1024.0 * 1024
return when {
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
else -> "%d КБ".format((bytes / 1024).toLong())
}
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.media.MirrorStatus
import pw.binom.viewmate.core.phone.trackFileName
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.FileDownloadState
import pw.binom.viewmate.phone.PhoneApp
@@ -392,26 +391,28 @@ fun DetailsScreen(
keys.forEach { key ->
val name = finalFileName(key)
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
val isVideo = key == "video"
val phoneRaw = phoneFileState(finalFile, partFile)
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
} else {
phoneRaw.second
}
val glassesState = if (glassesFully) {
FileDownloadState.DONE to 100
} else {
glassesFileState(perFileProgress[name])
// Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
val state = when {
isVideo && glassesFully -> FileDownloadState.DONE to 100
isVideo -> glassesFileState(perFileProgress[name])
else -> phoneRaw.first to phonePercent
}
DownloadFileRow(
name = name,
sizeLabel = fileSizeOrProgress(
sizeBytes = knownSizeByName[name],
phone = phoneRaw.first to phonePercent,
glasses = glassesState,
),
phone = phoneRaw.first to phonePercent,
glasses = glassesState,
device = if (isVideo) "очки" else "телефон",
sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
"⏳ ${state.second}%"
} else {
bytesToSize(knownSizeByName[name])
},
state = state,
)
}
}
@@ -498,24 +499,13 @@ fun DetailsScreen(
}
}
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */
private fun fileSizeOrProgress(
sizeBytes: Long?,
phone: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
): String = when {
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
else -> bytesToSize(sizeBytes)
}
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
/** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */
@Composable
private fun DownloadFileRow(
name: String,
device: String,
sizeLabel: String,
phone: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
state: Pair<FileDownloadState, Int>,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
@@ -523,13 +513,9 @@ private fun DownloadFileRow(
modifier = Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("телефон", color = PhoneMuted, fontSize = 9.sp)
Text(device, color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp))
FileChip(phone)
Spacer(modifier = Modifier.width(12.dp))
Text("очки", color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp))
FileChip(glasses)
FileChip(state)
}
}
}
@@ -5,34 +5,60 @@ 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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.viewmate.phone.MercuryBridge
/**
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
* Минимум: найти очки → подключить → погасить/включить экран (deepSuspend).
* Управление очками: подключение, экран, яркость и WiFi.
*/
@Composable
fun MercuryPanel() {
val scope = rememberCoroutineScope()
val state by MercuryBridge.state.collectAsState()
val busy = false
var brightness by remember { mutableIntStateOf(state.brightness) }
var autoBright by remember { mutableStateOf(state.automaticBrightness) }
var wifiOn by remember { mutableStateOf(state.wifiEnabled) }
var ssid by remember { mutableStateOf("") }
var wifiPass by remember { mutableStateOf("") }
var scanning by remember { mutableStateOf(false) }
// Синхронизируем локальные поля со статусом очков (после pullGeneralStatus).
LaunchedEffect(state.brightness) { brightness = state.brightness }
LaunchedEffect(state.automaticBrightness) { autoBright = state.automaticBrightness }
LaunchedEffect(state.wifiEnabled) { wifiOn = state.wifiEnabled }
Column(
modifier = Modifier
@@ -43,7 +69,7 @@ fun MercuryPanel() {
.padding(12.dp),
) {
Text(
text = "🛰 Mercury BLE (полевой тест)",
text = "🛰 Mercury BLE (управление очками)",
color = PhoneText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
@@ -65,12 +91,7 @@ fun MercuryPanel() {
Row(modifier = Modifier.padding(top = 10.dp)) {
Button(
onClick = {
scope.launch {
MercuryBridge.connectFirst()
}
},
enabled = !busy,
onClick = { scope.launch { MercuryBridge.connectFirst() } },
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
@@ -80,9 +101,7 @@ fun MercuryPanel() {
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
scope.launch { MercuryBridge.disconnect() }
},
onClick = { scope.launch { MercuryBridge.disconnect() } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
@@ -95,9 +114,7 @@ fun MercuryPanel() {
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = {
scope.launch { MercuryBridge.screenOff() }
},
onClick = { scope.launch { MercuryBridge.screenOff() } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2B2F36),
@@ -108,9 +125,7 @@ fun MercuryPanel() {
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
scope.launch { MercuryBridge.screenOn() }
},
onClick = { scope.launch { MercuryBridge.screenOn() } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneAccent,
@@ -120,5 +135,177 @@ fun MercuryPanel() {
Text("☀️ включить экран", fontSize = 12.sp)
}
}
// ---- Яркость ----
Text(
text = "Яркость: $brightness%",
color = PhoneText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 12.dp),
)
Slider(
value = brightness.toFloat(),
onValueChange = { brightness = it.roundToInt() },
valueRange = 0f..100f,
enabled = state.connected,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Применить яркость", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = autoBright,
onCheckedChange = { nv ->
autoBright = nv
scope.launch { MercuryBridge.setAutoBrightness(nv) }
},
enabled = state.connected,
)
Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp)
}
// ---- WiFi ----
Row(
modifier = Modifier.padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "WiFi",
color = PhoneText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = wifiOn,
onCheckedChange = { nv ->
wifiOn = nv
scope.launch { MercuryBridge.wifiToggle(nv) }
},
enabled = state.connected,
)
Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp)
Spacer(modifier = Modifier.width(10.dp))
Button(
onClick = { scope.launch {
if (!state.wifiEnabled) MercuryBridge.wifiToggle(true)
scanning = true
delay(1500) // дать радио подняться
MercuryBridge.wifiScan()
delay(5000) // окно ожидания ответа очков
scanning = false
} },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp)
}
if (state.wifiSsid != null) {
Text(
"· ${state.wifiSsid}",
color = PhoneBlueText,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
}
TextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text("SSID сети", fontSize = 11.sp) },
singleLine = true,
enabled = state.connected,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
TextField(
value = wifiPass,
onValueChange = { wifiPass = it },
label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) },
singleLine = true,
enabled = state.connected,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
Button(
onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } },
enabled = state.connected && ssid.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Подключить к сети", fontSize = 12.sp)
}
if (state.wifiNetworks.isEmpty() && !scanning) {
Text(
"Список пуст: включи WiFi и нажми «Сканировать».",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(top = 8.dp),
)
}
if (state.wifiNetworks.isNotEmpty()) {
Text(
"Найдено сетей: ${state.wifiNetworks.size}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(top = 8.dp),
)
LazyColumn(
modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp),
) {
items(state.wifiNetworks) { net ->
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Text(
"${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}",
color = PhoneText,
fontSize = 11.sp,
)
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Button(
onClick = { scope.launch {
val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null
MercuryBridge.wifiConnect(net.ssid, pwd)
} },
enabled = state.connected,
) {
Text("Подкл.", fontSize = 11.sp)
}
if (net.saved) {
Button(
onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
contentColor = PhoneMuted,
),
) {
Text("Забыть", fontSize = 11.sp)
}
}
}
}
}
}
}
}
}