Добавляем индикатор рассинхронизации звука и видео в статус-панели очков
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user