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
@@ -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() }
@@ -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 -> {
@@ -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) }
}
@@ -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