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:
@@ -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)}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (чистая функция, ветки причины «нет звука») ---
|
||||
|
||||
Reference in New Issue
Block a user