From e18b82642fc6f22ed31bcec41f2dd502569cfac7 Mon Sep 17 00:00:00 2001 From: subochev Date: Sun, 30 Aug 2026 05:08:37 +0300 Subject: [PATCH] =?UTF-8?q?audio-sync:=20=D1=83=D1=87=D0=B8=D1=82=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BB=D0=B0=D1=82=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BB=D0=B8=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BE=D1=87=D0=BA=D0=B8=E2=86=92=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D1=84=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Очки штампуют sentTs в PlaybackPosition; телефон мерит one-way латентность (receivedTs - sentTs, EWMA) и добавляет её к мастер-позиции в sync(), снимая системный перекос, из-за которого аудио недокручивало на величину транспортной задержки. Плюс правки тестов под HARD_SEEK_THRESHOLD_MS=5000 (остались незакомиченными с прошлого фикса). --- .../binom/viewmate/glasses/HostConnection.kt | 3 +- .../binom/viewmate/phone/AudioSyncPlayer.kt | 31 ++++++++++++++++--- .../pw/binom/viewmate/phone/GlassesServer.kt | 6 ++-- .../pw/binom/viewmate/phone/PhoneApp.kt | 4 +-- .../viewmate/phone/AudioSyncPlayerTest.kt | 10 +++--- .../binom/viewmate/core/protocol/Messages.kt | 2 ++ 6 files changed, 41 insertions(+), 15 deletions(-) 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 ffa9bc6..2bda3e8 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 @@ -101,6 +101,7 @@ class HostConnection( playing = snapshot.playing, itemId = snapshot.itemId, audioIndex = snapshot.audioIndex, + sentTs = System.currentTimeMillis(), ), ) } @@ -248,7 +249,7 @@ class HostConnection( scope.launch { val (positionMs, playing, itemId, audioIndex) = withContext(Dispatchers.Main) { movieController.playbackPosition() } - sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex)) + sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis())) } } 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 a143b65..a402ddf 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 @@ -49,6 +49,11 @@ internal const val MAX_DRIFT_SPEED = 0.10f */ internal const val HARD_SEEK_THRESHOLD_MS = 5000L +/** EWMA-сглаживание измеренной латентности линка (доля старого значения). */ +internal const val LINK_LATENCY_ALPHA = 0.8 +/** Верхний предел измеренной латентности: выше — артефакт (обрыв/зависание), игнорируем. */ +internal const val LINK_LATENCY_MAX_MS = 2000.0 + /** * Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android): * - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп); @@ -158,6 +163,9 @@ class AudioSyncPlayer(private val context: Context) { private var lastVideoPositionMs = 0L /** IsPlaying очков из последнего sync. */ private var lastVideoPlaying = false + /** Сглаженная (EWMA) латентность линка очки→телефон (мс): запаздывание мастер-позиции. */ + private var linkLatencyMs = 0.0 + private var lastLatencyLoggedAt = 0L private val _positionMs = MutableStateFlow(0L) /** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */ @@ -198,22 +206,37 @@ class AudioSyncPlayer(private val context: Context) { * при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f. * Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове. */ - fun sync(positionMs: Long, isPlaying: Boolean) { + fun sync(positionMs: Long, isPlaying: Boolean, sentTs: Long = 0L) { main.post { val p = player ?: return@post lastVideoPositionMs = positionMs lastVideoPlaying = isPlaying + // Латентность линка очки→телефон: сколько мс прошло от отправки позиции на очках до обработки здесь. + // Часы синхронизированы через mercury.syncTime(), так что это чистая транспортная задержка. + if (sentTs > 0L) { + val measured = (System.currentTimeMillis() - sentTs).toDouble() + if (measured in 0.0..LINK_LATENCY_MAX_MS) { + linkLatencyMs = linkLatencyMs * LINK_LATENCY_ALPHA + measured * (1 - LINK_LATENCY_ALPHA) + val now = System.currentTimeMillis() + if (now - lastLatencyLoggedAt > 5000) { + lastLatencyLoggedAt = now + log("audio sync:", "латентность линка: ${linkLatencyMs.toInt()} мс") + } + } + } + // Истинная позиция мастера = позиция в сообщении + латентность (очки ушли вперёд, пока пакет летел). + val videoPos = positionMs + linkLatencyMs.toLong() val audioPos = p.currentPosition - val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying) + 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 - positionMs)})") + log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)})") } else -> { p.setPlaybackParameters(PlaybackParameters(decision.speed)) - log("audio sync:", "video=$positionMs audio=$audioPos diff=${abs(audioPos - positionMs)} speed=${decision.speed}") + log("audio sync:", "video=$videoPos audio=$audioPos diff=${abs(audioPos - videoPos)} speed=${decision.speed}") } } decision.playing?.let { if (it) p.play() else p.pause() } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index c4363ad..9d702a7 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -97,7 +97,7 @@ class GlassesHub( * с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после * рестарта телефона (см. PhoneApp.autostartAudio). */ - var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null + var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long) -> Unit)? = null /** * Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор @@ -195,13 +195,13 @@ class GlassesHub( this.state.playing = msg.playing val state = if (msg.playing) "играет" else "пауза" log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}") - onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex) + onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs) } is GlassesOff -> { log("glasses", "очки выключились → пауза (${msg.reason})") this.state.playing = false - onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) + onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L) } is LogBatchMsg -> { 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 d7c23c7..2980d8e 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 @@ -473,10 +473,10 @@ class PhoneApp : Application() { // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера // + автоподхват звука (очки играют после рестарта телефона, audioSync не активен) - server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex -> + server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs -> lastPositionAt.set(System.currentTimeMillis()) lastWasPlaying.set(playing) - audioSync.sync(positionMs, playing) + audioSync.sync(positionMs, playing, sentTs) scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt index 0f44d92..6497ef4 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt @@ -69,15 +69,15 @@ class AudioSyncPlayerTest { @Test fun hardSeekWhenAudioFarBehind() { - val d = decideSync(audioPosition = 10_000, videoPosition = 12_500, audioPlaying = true, videoPlaying = true) - assertEquals(12_500L, d.seekToMs, "diff=2500мс > 2000мс — жёсткий seek на позицию очков") + val d = decideSync(audioPosition = 10_000, videoPosition = 16_000, audioPlaying = true, videoPlaying = true) + assertEquals(16_000L, d.seekToMs, "diff=6000мс > 5000мс — жёсткий seek на позицию очков") assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко") assertNull(d.playing) } @Test fun hardSeekWhenAudioFarAhead() { - val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true) + val d = decideSync(audioPosition = 26_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true) assertEquals(20_000L, d.seekToMs) assertEquals(1f, d.speed) } @@ -91,8 +91,8 @@ class AudioSyncPlayerTest { @Test fun hardThresholdPlusOneSeeks() { - val d = decideSync(audioPosition = 10_000, videoPosition = 12_001, audioPlaying = true, videoPlaying = true) - assertEquals(12_001L, d.seekToMs, "diff=2001мс > 2000мс — уже жёсткий seek") + val d = decideSync(audioPosition = 10_000, videoPosition = 15_001, audioPlaying = true, videoPlaying = true) + assertEquals(15_001L, d.seekToMs, "diff=5001мс > 5000мс — уже жёсткий seek") } @Test diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt index af36f7b..0761368 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt @@ -72,6 +72,8 @@ data class PlaybackPosition( val playing: Boolean, val itemId: String? = null, val audioIndex: Int = 0, + /** Время отправки на часах очков (epoch ms). Телефон мерит латентность линка = now − sentTs. 0 = не замерено. */ + val sentTs: Long = 0L, ) : GlassesToHost /** Очки выключились / экран погас. */