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
@@ -12,58 +12,98 @@ import kotlin.test.assertTrue
class AudioSyncPlayerTest {
@Test
fun noSeekWhenWithinThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_800, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=800мс ≤ 1000мс — seek не нужен")
assertNull(d.playing, "play/pause совпадают — не трогаем")
}
// --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek ---
@Test
fun thresholdBoundaryDiff1000DoesNotSeek() {
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем")
}
@Test
fun seeksToVideoPositionWhenDiffAboveThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f, d.speed, "полное совпадение — темп не трогаем")
assertNull(d.seekToMs)
assertNull(d.playing)
}
@Test
fun seeksWhenAudioAheadOfVideo() {
fun speedStaysFlatInsideTriggerBand() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_100, audioPlaying = true, videoPlaying = true)
assertEquals(1f, d.speed, "diff=100мс внутри гистерезиса [-150;+150] — дребезга нет")
assertNull(d.seekToMs)
assertNull(d.playing)
}
@Test
fun speedsUpWhenAudioLagsBehind() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_300, audioPlaying = true, videoPlaying = true)
assertEquals(1f + DRIFT_RATE, d.speed, "звук отстаёт — догоняем ускорением ${1f + DRIFT_RATE}")
assertNull(d.seekToMs, "diff=300мс внутри HARD-порога — seek не нужен")
assertNull(d.playing)
}
@Test
fun slowsDownWhenAudioAhead() {
val d = decideSync(audioPosition = 10_300, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f - DRIFT_RATE, d.speed, "звук впереди — тормозим до ${1f - DRIFT_RATE}")
assertNull(d.seekToMs)
assertNull(d.playing)
}
@Test
fun bandBoundaryExactly150MsStaysFlat() {
val ahead = decideSync(audioPosition = 10_150, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f, ahead.speed, "diff=+150мс — граница закрытой зоны, темп 1f")
val behind = decideSync(audioPosition = 9_850, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
assertEquals(1f, behind.speed, "diff=-150мс — граница закрытой зоны, темп 1f")
}
@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 на позицию очков")
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)
assertEquals(20_000L, d.seekToMs)
assertEquals(1f, d.speed)
}
@Test
fun hardThresholdBoundaryDoesNotSeek() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=2000мс не БОЛЬШЕ порога — подгоняем скоростью, без рывка")
assertEquals(1f + DRIFT_RATE, d.speed)
}
@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")
}
@Test
fun playWhenVideoPlayingButAudioPaused() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true)
assertNull(d.seekToMs)
assertEquals(1f, d.speed, "play/pause влияние только на playing, не на скорость")
assertEquals(true, d.playing, "очки играют — аудио должно играть")
assertNull(d.seekToMs)
}
@Test
fun pauseWhenVideoPausedButAudioPlaying() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
assertNull(d.seekToMs)
assertEquals(1f, d.speed)
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
assertNull(d.seekToMs)
}
@Test
fun seekAndPlayApplyTogether() {
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true)
assertEquals(8_000L, d.seekToMs)
val d = decideSync(audioPosition = 5_000, videoPosition = 15_000, audioPlaying = false, videoPlaying = true)
assertEquals(15_000L, d.seekToMs)
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
}
@Test
fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs)
assertNull(d.playing)
assertEquals(1f, d.speed)
}
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---