e18b82642f
Очки штампуют sentTs в PlaybackPosition; телефон мерит one-way латентность (receivedTs - sentTs, EWMA) и добавляет её к мастер-позиции в sync(), снимая системный перекос, из-за которого аудио недокручивало на величину транспортной задержки. Плюс правки тестов под HARD_SEEK_THRESHOLD_MS=5000 (остались незакомиченными с прошлого фикса).
188 lines
9.2 KiB
Kotlin
188 lines
9.2 KiB
Kotlin
package pw.binom.viewmate.phone
|
||
|
||
import pw.binom.viewmate.core.media.MirrorAudio
|
||
import pw.binom.viewmate.core.media.MirrorFiles
|
||
import pw.binom.viewmate.core.media.MirrorVideo
|
||
import java.io.File
|
||
import java.nio.file.Files
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.test.assertNull
|
||
import kotlin.test.assertTrue
|
||
|
||
class AudioSyncPlayerTest {
|
||
|
||
// --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek ---
|
||
|
||
@Test
|
||
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 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 + 300f * DRIFT_GAIN, d.speed, "звук отстаёт — догоняем ускорением ${1f + 300f * DRIFT_GAIN}")
|
||
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 - 300f * DRIFT_GAIN, d.speed, "звук впереди — тормозим до ${1f - 300f * DRIFT_GAIN}")
|
||
assertNull(d.seekToMs)
|
||
}
|
||
|
||
@Test
|
||
fun smallDriftJustOutsideBandIsProportional() {
|
||
val d = decideSync(audioPosition = 10_151, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||
assertEquals(1f - 151f * DRIFT_GAIN, d.speed, "diff=151мс (чуть за зоной) — слабый пропорциональный темп")
|
||
}
|
||
|
||
@Test
|
||
fun speedSaturatesAtMaxDrift() {
|
||
val behind = decideSync(audioPosition = 9_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||
assertEquals(1f + MAX_DRIFT_SPEED, behind.speed, "diff=−1000мс → 1+0.2 упирается в потолок ${1f + MAX_DRIFT_SPEED}")
|
||
val ahead = decideSync(audioPosition = 11_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
|
||
assertEquals(1f - MAX_DRIFT_SPEED, ahead.speed, "diff=+1000мс → 1−0.2 упирается в потолок ${1f - MAX_DRIFT_SPEED}")
|
||
}
|
||
|
||
@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 = 16_000, audioPlaying = true, videoPlaying = true)
|
||
assertEquals(16_000L, d.seekToMs, "diff=6000мс > 5000мс — жёсткий seek на позицию очков")
|
||
assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко")
|
||
assertNull(d.playing)
|
||
}
|
||
|
||
@Test
|
||
fun hardSeekWhenAudioFarAhead() {
|
||
val d = decideSync(audioPosition = 26_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 + MAX_DRIFT_SPEED, d.speed, "diff=2000мс → 1+0.4 — упирается в потолок ${1f + MAX_DRIFT_SPEED}")
|
||
}
|
||
|
||
@Test
|
||
fun hardThresholdPlusOneSeeks() {
|
||
val d = decideSync(audioPosition = 10_000, videoPosition = 15_001, audioPlaying = true, videoPlaying = true)
|
||
assertEquals(15_001L, d.seekToMs, "diff=5001мс > 5000мс — уже жёсткий seek")
|
||
}
|
||
|
||
@Test
|
||
fun playWhenVideoPlayingButAudioPaused() {
|
||
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true)
|
||
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)
|
||
assertEquals(1f, d.speed)
|
||
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
|
||
assertNull(d.seekToMs)
|
||
}
|
||
|
||
@Test
|
||
fun seekAndPlayApplyTogether() {
|
||
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")
|
||
assertEquals(1f, d.speed)
|
||
}
|
||
|
||
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
|
||
|
||
private fun mirrorFiles(audios: List<MirrorAudio> = listOf()) = MirrorFiles(
|
||
video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"),
|
||
audios = audios,
|
||
)
|
||
|
||
private fun audioDir(): File = Files.createTempDirectory("audio-src-test").toFile()
|
||
|
||
@Test
|
||
fun sourceIsNullWhenMirrorFilesMissing() {
|
||
val result = resolveAudioSourceDetailed(null, 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
|
||
assertNull(result.source)
|
||
assertTrue(result.reason.contains("null"), "причина должна указывать на отсутствующее зеркало: ${result.reason}")
|
||
}
|
||
|
||
@Test
|
||
fun sourceIsNullWhenAudiosEmpty() {
|
||
val result = resolveAudioSourceDetailed(mirrorFiles(), 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
|
||
assertNull(result.source)
|
||
assertTrue(result.reason.contains("audios пусто"), "причина должна указывать на пустой список дорожек: ${result.reason}")
|
||
}
|
||
|
||
@Test
|
||
fun usesRequestedAudioIndex() {
|
||
val dir = audioDir()
|
||
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
|
||
val audios = listOf(
|
||
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
|
||
MirrorAudio(index = 1, key = "a1", url = "https://s3/audio-1.ogg"),
|
||
)
|
||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 1, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||
assertEquals(File(dir, "audio-1.ogg").absolutePath, result.source)
|
||
assertTrue(result.reason.contains("локальный файл"), "выбрана именно дорожка #1")
|
||
}
|
||
|
||
@Test
|
||
fun fallsBackToFirstAudioWhenIndexMissing() {
|
||
val dir = audioDir()
|
||
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
|
||
val audios = listOf(
|
||
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
|
||
MirrorAudio(index = 2, key = "a2", url = "https://s3/audio-2.ogg"),
|
||
)
|
||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 7, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||
assertEquals(File(dir, "audio-0.ogg").absolutePath, result.source, "нет #7 — берём первую дорожку")
|
||
}
|
||
|
||
@Test
|
||
fun usesLocalFileWhenDownloaded() {
|
||
val dir = audioDir()
|
||
val local = File(dir, "audio-3.ogg").apply { writeBytes(ByteArray(10)) }
|
||
val audios = listOf(MirrorAudio(index = 3, key = "a3", url = "https://s3/audio-3.ogg"))
|
||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 3, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||
assertEquals(local.absolutePath, result.source)
|
||
}
|
||
|
||
@Test
|
||
fun streamsPresignedUrlWhenNoLocalFile() {
|
||
val dir = audioDir()
|
||
val audios = listOf(MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"))
|
||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 0, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||
assertEquals("signed:https://s3/audio-0.ogg", result.source)
|
||
assertTrue(result.reason.contains("стриминг"), "без локального файла — стриминг: ${result.reason}")
|
||
}
|
||
}
|