Добавляем индикатор рассинхронизации звука и видео в статус-панели очков

This commit is contained in:
2026-09-02 19:27:11 +03:00
parent 92b483a5e3
commit b2e2d2697d
7 changed files with 92 additions and 6 deletions
@@ -102,6 +102,13 @@ class GlassesApp : Application() {
*/
val answerScrollDelta = MutableStateFlow(0f)
/**
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
* (видео на очках не играет / данных ещё нет).
*/
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
/** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null
@@ -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 -> {
@@ -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))
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
@@ -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<Long> = _positionMs.asStateFlow()
private val _diffMs = MutableStateFlow(0L)
/** |звук − видео| из последнего sync-цикла (мс), для индикатора статус-панели очков. 0 — синхронизации не было. */
val diffMs: StateFlow<Long> = _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()
@@ -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) {
@@ -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")
@@ -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<AudioSyncInfo>(decoded)
assertEquals(340, decoded.diffMs)
assertTrue(decoded.active)
}
}