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