Добавляем индикатор рассинхронизации звука и видео в статус-панели очков
This commit is contained in:
@@ -102,6 +102,13 @@ class GlassesApp : Application() {
|
|||||||
*/
|
*/
|
||||||
val answerScrollDelta = MutableStateFlow(0f)
|
val answerScrollDelta = MutableStateFlow(0f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
||||||
|
* (AudioSyncInfo от телефона каждый sync-цикл ~5с). null — не выводить
|
||||||
|
* (видео на очках не играет / данных ещё нет).
|
||||||
|
*/
|
||||||
|
val audioSyncDiffMs = MutableStateFlow<Int?>(null)
|
||||||
|
|
||||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||||
var mainActivity: MainActivity? = null
|
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.GlassesTransport
|
||||||
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
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.ChatHistoryItem
|
||||||
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||||
@@ -338,6 +339,16 @@ class HostConnection(
|
|||||||
downloadManager.handleDeleteFiles(msg.itemId)
|
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 AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
|
||||||
|
|
||||||
is ShowText -> {
|
is ShowText -> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -18,6 +19,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pw.binom.viewmate.glasses.GlassesApp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
|
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
|
||||||
@@ -36,12 +38,20 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
|
|||||||
val d = data ?: return
|
val d = data ?: return
|
||||||
val baseStyle = MaterialTheme.typography.bodyMedium
|
val baseStyle = MaterialTheme.typography.bodyMedium
|
||||||
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
|
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
|
||||||
|
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
|
||||||
|
// + свежее значение от телефона); без видео — ничего не выводить.
|
||||||
|
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
|
||||||
|
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState()
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(Color(0x99000000)) // полупрозрачный чёрный
|
.background(Color(0x99000000)) // полупрозрачный чёрный
|
||||||
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
|
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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)
|
Text(d.time, color = Color.White, style = style)
|
||||||
Spacer(Modifier.width(6.dp * scale))
|
Spacer(Modifier.width(6.dp * scale))
|
||||||
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
|
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
|
||||||
|
|||||||
@@ -149,12 +149,20 @@ class AudioSyncPlayer(private val context: Context) {
|
|||||||
private var player: ExoPlayer? = null
|
private var player: ExoPlayer? = null
|
||||||
private var ticker: Job? = null
|
private var ticker: Job? = null
|
||||||
|
|
||||||
/** Слушатель буферизации ExoPlayer: логирует паузы вывода (причина «немого» звука). */
|
/** Слушатель буферизации ExoPlayer: логирует паузы вывода с длительностью (причина «немого» звука:
|
||||||
|
* длительная буферизация = локальная/S3-дорога, короткая/абсолютное отсутствие = BT-стек). */
|
||||||
private val bufferingListener = object : Player.Listener {
|
private val bufferingListener = object : Player.Listener {
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
when (playbackState) {
|
when (playbackState) {
|
||||||
Player.STATE_BUFFERING -> log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)")
|
Player.STATE_BUFFERING -> {
|
||||||
Player.STATE_READY -> log("audio", "буфер готов, вывод возобновлён")
|
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 linkLatencyMs = 0.0
|
||||||
private var lastLatencyLoggedAt = 0L
|
private var lastLatencyLoggedAt = 0L
|
||||||
|
|
||||||
|
/** Текущий источник для логов: «стриминг S3» или «локальный файл» — виден в каждой периодической
|
||||||
|
* строке «audio sync:», чтобы любой фрагмент logcat отвечал на вопрос «стримлю или скачанное?». */
|
||||||
|
private var sourceLabel = "нет"
|
||||||
|
|
||||||
|
/** Момент входа в STATE_BUFFERING (для лога длительности; 0 — не в буферизации). */
|
||||||
|
private var bufferingStartMs = 0L
|
||||||
|
|
||||||
private val _positionMs = MutableStateFlow(0L)
|
private val _positionMs = MutableStateFlow(0L)
|
||||||
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
|
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
|
||||||
val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
|
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
|
val isActive: Boolean
|
||||||
get() = player != null
|
get() = player != null
|
||||||
@@ -195,7 +218,8 @@ class AudioSyncPlayer(private val context: Context) {
|
|||||||
p.seekTo(startPositionMs.coerceAtLeast(0L))
|
p.seekTo(startPositionMs.coerceAtLeast(0L))
|
||||||
p.playWhenReady = true
|
p.playWhenReady = true
|
||||||
val isStream = urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://")
|
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()
|
startTicker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,16 +251,17 @@ class AudioSyncPlayer(private val context: Context) {
|
|||||||
// Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел).
|
// Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел).
|
||||||
val videoPos = positionMs + linkLatencyMs.toLong()
|
val videoPos = positionMs + linkLatencyMs.toLong()
|
||||||
val audioPos = p.currentPosition
|
val audioPos = p.currentPosition
|
||||||
|
_diffMs.value = abs(audioPos - videoPos)
|
||||||
val decision = decideSync(audioPos, videoPos, p.isPlaying, isPlaying)
|
val decision = decideSync(audioPos, videoPos, p.isPlaying, isPlaying)
|
||||||
when {
|
when {
|
||||||
decision.seekToMs != null -> {
|
decision.seekToMs != null -> {
|
||||||
p.seekTo(decision.seekToMs)
|
p.seekTo(decision.seekToMs)
|
||||||
p.setPlaybackParameters(PlaybackParameters(1f))
|
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 -> {
|
else -> {
|
||||||
p.setPlaybackParameters(PlaybackParameters(decision.speed))
|
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() }
|
decision.playing?.let { if (it) p.play() else p.pause() }
|
||||||
@@ -262,7 +287,10 @@ class AudioSyncPlayer(private val context: Context) {
|
|||||||
ticker = null
|
ticker = null
|
||||||
val p = player
|
val p = player
|
||||||
player = null
|
player = null
|
||||||
|
lastVideoPlaying = false
|
||||||
|
sourceLabel = "нет"
|
||||||
_positionMs.value = 0L
|
_positionMs.value = 0L
|
||||||
|
_diffMs.value = 0L
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
p.playWhenReady = false
|
p.playWhenReady = false
|
||||||
p.stop()
|
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.phone.PhoneState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantState
|
import pw.binom.viewmate.core.protocol.AssistantState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
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.ChatHistoryItem
|
||||||
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||||
import pw.binom.viewmate.core.protocol.ShowText
|
import pw.binom.viewmate.core.protocol.ShowText
|
||||||
@@ -507,6 +508,16 @@ class PhoneApp : Application() {
|
|||||||
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
|
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: очки молчат (экран погас/радио спит) — пауза звука по таймауту
|
// watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ data class SetScale(val scale: Float) : HostToGlasses
|
|||||||
@SerialName("set_vertical")
|
@SerialName("set_vertical")
|
||||||
data class SetVertical(val vertical: Float) : HostToGlasses
|
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 — весь накопленный текст сессии. */
|
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("stt_phrase")
|
@SerialName("stt_phrase")
|
||||||
|
|||||||
@@ -265,4 +265,15 @@ class HostToGlassesTest {
|
|||||||
assertEquals("привет", decoded.messages[0].content)
|
assertEquals("привет", decoded.messages[0].content)
|
||||||
assertEquals("assistant", decoded.messages[1].role)
|
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