audio-sync: учитывать латентность линка очки→телефон

Очки штампуют sentTs в PlaybackPosition; телефон мерит one-way
латентность (receivedTs - sentTs, EWMA) и добавляет её к мастер-позиции
в sync(), снимая системный перекос, из-за которого аудио недокручивало
на величину транспортной задержки. Плюс правки тестов под
HARD_SEEK_THRESHOLD_MS=5000 (остались незакомиченными с прошлого фикса).
This commit is contained in:
2026-08-30 05:08:37 +03:00
parent b11879520e
commit e18b82642f
6 changed files with 41 additions and 15 deletions
@@ -101,6 +101,7 @@ class HostConnection(
playing = snapshot.playing, playing = snapshot.playing,
itemId = snapshot.itemId, itemId = snapshot.itemId,
audioIndex = snapshot.audioIndex, audioIndex = snapshot.audioIndex,
sentTs = System.currentTimeMillis(),
), ),
) )
} }
@@ -248,7 +249,7 @@ class HostConnection(
scope.launch { scope.launch {
val (positionMs, playing, itemId, audioIndex) = val (positionMs, playing, itemId, audioIndex) =
withContext(Dispatchers.Main) { movieController.playbackPosition() } withContext(Dispatchers.Main) { movieController.playbackPosition() }
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex)) sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis()))
} }
} }
@@ -49,6 +49,11 @@ internal const val MAX_DRIFT_SPEED = 0.10f
*/ */
internal const val HARD_SEEK_THRESHOLD_MS = 5000L 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): * Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп); * - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
@@ -158,6 +163,9 @@ class AudioSyncPlayer(private val context: Context) {
private var lastVideoPositionMs = 0L private var lastVideoPositionMs = 0L
/** IsPlaying очков из последнего sync. */ /** IsPlaying очков из последнего sync. */
private var lastVideoPlaying = false private var lastVideoPlaying = false
/** Сглаженная (EWMA) латентность линка очки→телефон (мс): запаздывание мастер-позиции. */
private var linkLatencyMs = 0.0
private var lastLatencyLoggedAt = 0L
private val _positionMs = MutableStateFlow(0L) private val _positionMs = MutableStateFlow(0L)
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */ /** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
@@ -198,22 +206,37 @@ class AudioSyncPlayer(private val context: Context) {
* при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f. * при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f.
* Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове. * Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове.
*/ */
fun sync(positionMs: Long, isPlaying: Boolean) { fun sync(positionMs: Long, isPlaying: Boolean, sentTs: Long = 0L) {
main.post { main.post {
val p = player ?: return@post val p = player ?: return@post
lastVideoPositionMs = positionMs lastVideoPositionMs = positionMs
lastVideoPlaying = isPlaying 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 audioPos = p.currentPosition
val decision = decideSync(audioPos, positionMs, 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 - positionMs)})") log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - videoPos)})")
} }
else -> { else -> {
p.setPlaybackParameters(PlaybackParameters(decision.speed)) 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() } decision.playing?.let { if (it) p.play() else p.pause() }
@@ -97,7 +97,7 @@ class GlassesHub(
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после * с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
* рестарта телефона (см. PhoneApp.autostartAudio). * рестарта телефона (см. 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 this.state.playing = msg.playing
val state = if (msg.playing) "играет" else "пауза" val state = if (msg.playing) "играет" else "пауза"
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}") 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 -> { is GlassesOff -> {
log("glasses", "очки выключились → пауза (${msg.reason})") log("glasses", "очки выключились → пауза (${msg.reason})")
this.state.playing = false this.state.playing = false
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L)
} }
is LogBatchMsg -> { is LogBatchMsg -> {
@@ -473,10 +473,10 @@ class PhoneApp : Application() {
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен) // + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex -> server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs ->
lastPositionAt.set(System.currentTimeMillis()) lastPositionAt.set(System.currentTimeMillis())
lastWasPlaying.set(playing) lastWasPlaying.set(playing)
audioSync.sync(positionMs, playing) audioSync.sync(positionMs, playing, sentTs)
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) } scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
} }
@@ -69,15 +69,15 @@ class AudioSyncPlayerTest {
@Test @Test
fun hardSeekWhenAudioFarBehind() { fun hardSeekWhenAudioFarBehind() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_500, audioPlaying = true, videoPlaying = true) val d = decideSync(audioPosition = 10_000, videoPosition = 16_000, audioPlaying = true, videoPlaying = true)
assertEquals(12_500L, d.seekToMs, "diff=2500мс > 2000мс — жёсткий seek на позицию очков") assertEquals(16_000L, d.seekToMs, "diff=6000мс > 5000мс — жёсткий seek на позицию очков")
assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко") assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко")
assertNull(d.playing) assertNull(d.playing)
} }
@Test @Test
fun hardSeekWhenAudioFarAhead() { 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(20_000L, d.seekToMs)
assertEquals(1f, d.speed) assertEquals(1f, d.speed)
} }
@@ -91,8 +91,8 @@ class AudioSyncPlayerTest {
@Test @Test
fun hardThresholdPlusOneSeeks() { fun hardThresholdPlusOneSeeks() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_001, audioPlaying = true, videoPlaying = true) val d = decideSync(audioPosition = 10_000, videoPosition = 15_001, audioPlaying = true, videoPlaying = true)
assertEquals(12_001L, d.seekToMs, "diff=2001мс > 2000мс — уже жёсткий seek") assertEquals(15_001L, d.seekToMs, "diff=5001мс > 5000мс — уже жёсткий seek")
} }
@Test @Test
@@ -72,6 +72,8 @@ data class PlaybackPosition(
val playing: Boolean, val playing: Boolean,
val itemId: String? = null, val itemId: String? = null,
val audioIndex: Int = 0, val audioIndex: Int = 0,
/** Время отправки на часах очков (epoch ms). Телефон мерит латентность линка = now − sentTs. 0 = не замерено. */
val sentTs: Long = 0L,
) : GlassesToHost ) : GlassesToHost
/** Очки выключились / экран погас. */ /** Очки выключились / экран погас. */