audio-sync: учитывать латентность линка очки→телефон
Очки штампуют sentTs в PlaybackPosition; телефон мерит one-way латентность (receivedTs - sentTs, EWMA) и добавляет её к мастер-позиции в sync(), снимая системный перекос, из-за которого аудио недокручивало на величину транспортной задержки. Плюс правки тестов под HARD_SEEK_THRESHOLD_MS=5000 (остались незакомиченными с прошлого фикса).
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
/** Очки выключились / экран погас. */
|
/** Очки выключились / экран погас. */
|
||||||
|
|||||||
Reference in New Issue
Block a user