Files
view-mate/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt
T
subochev e18b82642f audio-sync: учитывать латентность линка очки→телефон
Очки штампуют sentTs в PlaybackPosition; телефон мерит one-way
латентность (receivedTs - sentTs, EWMA) и добавляет её к мастер-позиции
в sync(), снимая системный перекос, из-за которого аудио недокручивало
на величину транспортной задержки. Плюс правки тестов под
HARD_SEEK_THRESHOLD_MS=5000 (остались незакомиченными с прошлого фикса).
2026-08-30 05:08:37 +03:00

188 lines
9.2 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}")
}
}