soft sync audio via PlaybackParameters speed (rate-based, no seekTo jump)

Master = glasses (5s PlaybackPosition), slave = phone audio.
Replace aggressive seekTo-drift-corrector with smooth tempo correction:
- |diff|<=150ms (TRIGGER_BAND) -> speed 1f (hysteresis, no flutter)
- audio lags -> speed 1.02 (catch up)
- audio ahead -> speed 0.98 (slow down)
- |diff|>2000ms -> rare hard seekTo + reset speed 1f
- removed catch-up seekTo after +1s (was adding ~2s error)
- log now prints speed
AudioSyncPlayerTest: 18 cases (boundaries 150/2000, speed up/down/1f, play/pause).
This commit is contained in:
2026-08-27 23:23:50 +03:00
parent cf8c5ef604
commit 62d2f06797
3 changed files with 191 additions and 52 deletions
@@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -20,34 +21,50 @@ import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File
import kotlin.math.abs
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */
/** Решение одного sync-цикла: speed — желаемая скорость аудио; seekToMs не null → редкий жёсткий seek; playing не null → play/pause. */
internal data class SyncDecision(
val speed: Float = 1f,
val seekToMs: Long? = null,
val playing: Boolean? = null,
)
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */
internal const val SYNC_THRESHOLD_MS = 1000L
/** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
internal const val TRIGGER_BAND_MS = 150L
/** Темп догонки/отстаивания за пределами гистерезиса: 1.02 / 0.98 на каждый цикл. */
internal const val DRIFT_RATE = 0.02f
/** Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo. */
internal const val HARD_SEEK_THRESHOLD_MS = 2000L
/**
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |audio - video| > [thresholdMs] → перемотать на позицию очков;
* - isPlaying очков отличается от состояния аудио → play/pause.
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
* - audio отстаёт от video (diff < -band) → speed = 1 + [DRIFT_RATE] (нагоняем);
* - audio впереди video (diff > +band) → speed = 1 - [DRIFT_RATE] (тормозим);
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
*/
internal fun decideSync(
audioPosition: Long,
videoPosition: Long,
audioPlaying: Boolean,
videoPlaying: Boolean,
thresholdMs: Long = SYNC_THRESHOLD_MS,
): SyncDecision {
val seekToMs = if (abs(audioPosition - videoPosition) > thresholdMs) videoPosition else null
val diff = audioPosition - videoPosition
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
val speed = when {
seekToMs != null -> 1f
diff < -TRIGGER_BAND_MS -> 1f + DRIFT_RATE
diff > TRIGGER_BAND_MS -> 1f - DRIFT_RATE
else -> 1f
}
val playing = when {
videoPlaying == audioPlaying -> null
videoPlaying -> true
else -> false
}
return SyncDecision(seekToMs = seekToMs, playing = playing)
return SyncDecision(speed = speed, seekToMs = seekToMs, playing = playing)
}
/**
@@ -115,7 +132,7 @@ class AudioSyncPlayer(private val context: Context) {
private var player: ExoPlayer? = null
private var ticker: Job? = null
/** Позиция очков из последнего sync (для догонки после неточного seekTo). */
/** Позиция очков из последнего sync. */
private var lastVideoPositionMs = 0L
/** IsPlaying очков из последнего sync. */
private var lastVideoPlaying = false
@@ -150,8 +167,10 @@ class AudioSyncPlayer(private val context: Context) {
}
/**
* Коррекция по позиции очков: diff > 1000 мс → seek, play/pause по isPlaying.
* Пишет ключевой лог для QA при каждом вызове.
* Коррекция по позиции очков: за пределами гистерезис-зоны скорость плавно
* подгоняется через setPlaybackParameters (без seekTo); жёсткий seek — только
* при рассинхроне > [HARD_SEEK_THRESHOLD_MS] и после seek скорость возвращается в 1f.
* Play/pause по isPlaying очков. Пишет ключевой лог для QA при каждом вызове.
*/
fun sync(positionMs: Long, isPlaying: Boolean) {
main.post {
@@ -160,22 +179,18 @@ class AudioSyncPlayer(private val context: Context) {
lastVideoPlaying = isPlaying
val audioPos = p.currentPosition
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying)
decision.seekToMs?.let {
p.seekTo(it)
lastVideoPositionMs = it
// Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с),
// второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с).
main.postDelayed({
val pp = player ?: return@postDelayed
val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду
if (abs(pp.currentPosition - target) > 500) {
pp.seekTo(target)
log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})")
}
}, 1_000)
when {
decision.seekToMs != null -> {
p.seekTo(decision.seekToMs)
p.setPlaybackParameters(PlaybackParameters(1f))
log("audio sync:", "seek → ${decision.seekToMs}")
}
else -> {
p.setPlaybackParameters(PlaybackParameters(decision.speed))
log("audio sync:", "video=$positionMs audio=$audioPos diff=${abs(audioPos - positionMs)} speed=${decision.speed}")
}
}
decision.playing?.let { if (it) p.play() else p.pause() }
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
}
}