From b2e2d2697d96862b63f4cd26af23edb465135317 Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 2 Sep 2026 19:27:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D1=80=D0=B0=D1=81=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B7=D0=B2=D1=83?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20=D0=B2?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81-=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D0=BE=D1=87=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pw/binom/viewmate/glasses/GlassesApp.kt | 7 ++++ .../binom/viewmate/glasses/HostConnection.kt | 11 +++++ .../glasses/status/StatusPanelCompose.kt | 10 +++++ .../binom/viewmate/phone/AudioSyncPlayer.kt | 40 ++++++++++++++++--- .../pw/binom/viewmate/phone/PhoneApp.kt | 11 +++++ .../viewmate/core/protocol/HostToGlasses.kt | 8 ++++ .../core/protocol/HostToGlassesTest.kt | 11 +++++ 7 files changed, 92 insertions(+), 6 deletions(-) diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 3ce6558..1975ce1 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -102,6 +102,13 @@ class GlassesApp : Application() { */ val answerScrollDelta = MutableStateFlow(0f) + /** + * Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс + * (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить + * (видео на очках не играет / данных ещё нет). + */ + val audioSyncDiffMs = MutableStateFlow(null) + /** Текущая Activity (для закрытия приложения по команде с телефона). */ var mainActivity: MainActivity? = null diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 2bda3e8..443b83f 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -22,6 +22,7 @@ import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.net.GlassesTransport import pw.binom.viewmate.core.net.WifiGlassesTransport import pw.binom.viewmate.core.protocol.AssistantStateMsg +import pw.binom.viewmate.core.protocol.AudioSyncInfo import pw.binom.viewmate.core.protocol.ChatHistoryItem import pw.binom.viewmate.core.protocol.ChatHistoryMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg @@ -338,6 +339,16 @@ class HostConnection( downloadManager.handleDeleteFiles(msg.itemId) } + is AudioSyncInfo -> { + // Индикатор |звук − видео| для статус-панели; active=false (видео не играет) — скрываем. + val prev = GlassesApp.instance.audioSyncDiffMs.value + GlassesApp.instance.audioSyncDiffMs.value = if (msg.active) msg.diffMs else null + // Диагностика: скачок diff (по часам очков) — для корреляции с logcat BT-стека телефона за ту же секунду. + if (msg.active && prev != null && msg.diffMs - prev >= 300) { + log("audio", "diff скачок $prev→${msg.diffMs} мс") + } + } + is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)") is ShowText -> { diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt index e05786a..0c590d6 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import pw.binom.viewmate.glasses.GlassesApp /** * Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content, @@ -36,12 +38,20 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) { val d = data ?: return val baseStyle = MaterialTheme.typography.bodyMedium val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale) + // Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive + // + свежее значение от телефона); без видео — ничего не выводить. + val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState() + val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState() Row( modifier = modifier .background(Color(0x99000000)) // полупрозрачный чёрный .padding(horizontal = 10.dp * scale, vertical = 4.dp * scale), verticalAlignment = Alignment.CenterVertically, ) { + if (movieActive.value && diffMs.value != null) { + Text("${diffMs.value} мс", color = Color(0xFF999999), style = style) + Spacer(Modifier.width(6.dp * scale)) + } Text(d.time, color = Color.White, style = style) Spacer(Modifier.width(6.dp * scale)) // Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика. diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt index a402ddf..5e5891c 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt @@ -149,12 +149,20 @@ class AudioSyncPlayer(private val context: Context) { private var player: ExoPlayer? = null private var ticker: Job? = null - /** Слушатель буферизации ExoPlayer: логирует паузы вывода (причина «немого» звука). */ + /** Слушатель буферизации ExoPlayer: логирует паузы вывода с длительностью (причина «немого» звука: + * длительная буферизация = локальная/S3-дорога, короткая/абсолютное отсутствие = BT-стек). */ private val bufferingListener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { - Player.STATE_BUFFERING -> log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)") - Player.STATE_READY -> log("audio", "буфер готов, вывод возобновлён") + Player.STATE_BUFFERING -> { + bufferingStartMs = System.currentTimeMillis() + log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)") + } + Player.STATE_READY -> { + val bufferedMs = if (bufferingStartMs > 0L) System.currentTimeMillis() - bufferingStartMs else 0L + bufferingStartMs = 0L + log("audio", "буфер готов, вывод возобновлён${if (bufferedMs > 0L) " (буферизация ${bufferedMs} мс)" else ""}") + } } } } @@ -167,10 +175,25 @@ class AudioSyncPlayer(private val context: Context) { private var linkLatencyMs = 0.0 private var lastLatencyLoggedAt = 0L + /** Текущий источник для логов: «стриминг S3» или «локальный файл» — виден в каждой периодической + * строке «audio sync:», чтобы любой фрагмент logcat отвечал на вопрос «стримлю или скачанное?». */ + private var sourceLabel = "нет" + + /** Момент входа в STATE_BUFFERING (для лога длительности; 0 — не в буферизации). */ + private var bufferingStartMs = 0L + private val _positionMs = MutableStateFlow(0L) /** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */ val positionMs: StateFlow = _positionMs.asStateFlow() + private val _diffMs = MutableStateFlow(0L) + /** |звук − видео| из последнего sync-цикла (мс), для индикатора статус-панели очков. 0 — синхронизации не было. */ + val diffMs: StateFlow = _diffMs.asStateFlow() + + /** Играет ли видео на очках (по последнему PlaybackPosition). Только с главного потока. */ + val videoPlaying: Boolean + get() = lastVideoPlaying + /** Есть ли активный аудио-плеер (просмотр на очках). */ val isActive: Boolean get() = player != null @@ -195,7 +218,8 @@ class AudioSyncPlayer(private val context: Context) { p.seekTo(startPositionMs.coerceAtLeast(0L)) p.playWhenReady = true val isStream = urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://") - log("audio", "играю ${if (isStream) "стриминг S3" else "локальный файл"}: $urlOrFile с ${startPositionMs} мс") + sourceLabel = if (isStream) "стриминг S3" else "локальный файл" + log("audio", "играю $sourceLabel: $urlOrFile с ${startPositionMs} мс") startTicker() } } @@ -227,16 +251,17 @@ class AudioSyncPlayer(private val context: Context) { // Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел). val videoPos = positionMs + linkLatencyMs.toLong() val audioPos = p.currentPosition + _diffMs.value = abs(audioPos - videoPos) val decision = decideSync(audioPos, videoPos, p.isPlaying, isPlaying) when { decision.seekToMs != null -> { p.seekTo(decision.seekToMs) p.setPlaybackParameters(PlaybackParameters(1f)) - log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)})") + log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)} src=$sourceLabel)") } else -> { p.setPlaybackParameters(PlaybackParameters(decision.speed)) - log("audio sync:", "video=$videoPos audio=$audioPos diff=${abs(audioPos - videoPos)} speed=${decision.speed}") + log("audio sync:", "video=$videoPos audio=$audioPos diff=${abs(audioPos - videoPos)} speed=${decision.speed} src=$sourceLabel") } } decision.playing?.let { if (it) p.play() else p.pause() } @@ -262,7 +287,10 @@ class AudioSyncPlayer(private val context: Context) { ticker = null val p = player player = null + lastVideoPlaying = false + sourceLabel = "нет" _positionMs.value = 0L + _diffMs.value = 0L if (p != null) { p.playWhenReady = false p.stop() diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index e25aff0..e766342 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -28,6 +28,7 @@ import pw.binom.viewmate.core.phone.PhoneActions import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.protocol.AssistantState import pw.binom.viewmate.core.protocol.AssistantStateMsg +import pw.binom.viewmate.core.protocol.AudioSyncInfo import pw.binom.viewmate.core.protocol.ChatHistoryItem import pw.binom.viewmate.core.protocol.ChatHistoryMsg import pw.binom.viewmate.core.protocol.ShowText @@ -507,6 +508,16 @@ class PhoneApp : Application() { scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) } } + // Индикатор аудио-синхронизации в статус-панели очков: |звук − видео| в мс + // на каждый sync-цикл (~5с); active=false (видео на очках не играет) — панель скрывает. + scope.launch { + audioSync.diffMs.collect { diff -> + server.hub.broadcast( + AudioSyncInfo(diffMs = diff.toInt(), active = audioSync.isActive && audioSync.videoPlaying), + ) + } + } + // watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту scope.launch { while (true) { diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt index 1daa459..c2868d3 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -86,6 +86,14 @@ data class SetScale(val scale: Float) : HostToGlasses @SerialName("set_vertical") data class SetVertical(val vertical: Float) : HostToGlasses +/** + * Индикатор аудио-синхронизации для статус-панели очков: diffMs — |звук − видео| в миллисекундах + * (каждый sync-цикл ~5с), active — сейчас ли видео играет на очках (иначе индикатор скрывают). + */ +@Serializable +@SerialName("audio_sync_info") +data class AudioSyncInfo(val diffMs: Int, val active: Boolean) : HostToGlasses + /** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */ @Serializable @SerialName("stt_phrase") diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt index bec9fe1..0502cd8 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -265,4 +265,15 @@ class HostToGlassesTest { assertEquals("привет", decoded.messages[0].content) assertEquals("assistant", decoded.messages[1].role) } + + @Test + fun audioSyncInfoRoundTrip() { + val msg: HostToGlasses = AudioSyncInfo(diffMs = 340, active = true) + val text = json.encodeToString(HostToGlasses.serializer(), msg) + assertEquals("""{"type":"audio_sync_info","diffMs":340,"active":true}""", text) + val decoded = json.decodeFromString(HostToGlasses.serializer(), text) + assertIs(decoded) + assertEquals(340, decoded.diffMs) + assertTrue(decoded.active) + } }