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
+84
View File
@@ -0,0 +1,84 @@
# TASK: мягкая синхронизация аудио через скорость (rate-based sync)
## Контекст / проблема
В `view-mate` звук играет на **телефоне** (ExoPlayer media3), видео — на **очках** (очки — мастер, шлют позицию каждые 5с через `PlaybackPosition`). Телефон подстраивает аудио под позицию очков.
Сейчас коррекция в `AudioSyncPlayer.sync()` грубая:
- раз в 5с (на каждый PlaybackPosition от очков) вызывается `sync(positionMs, isPlaying)`;
- если `abs(audioPos - videoPos) > 1000` → **`seekTo(videoPos)`** — резкий рывок;
- после seek ещё «догонка» через 1с (второй `seekTo`).
Проблема: звук на телефоне стабильно уезжает на ~1с вперёд от видео очков (статика `diff=1000` в паузе; при живом просмотре набирает >1000). Грубый `seekTo` при каждом пересечении порога рождает **повтор фразы** («нас заметили … нас заметили») и кодечки — слышимый дефект.
## Задача
Заменить резкий `seekTo`-корректор на **мягкую коррекцию через темп воспроизведения (PlaybackParameters.speed)**. Очки остаются мастером, телефон — ведомым: аудио подстраивается под позицию очков плавно, без рывков и повторов.
## Конкретные требования
### 1. Чистая функция решателя (заменить `decideSync`)
Переписать `decideSync` (сейчас возвращает `SyncDecision(seekToMs, playing)`) так, чтобы он возвращал **скорость** и лишь экстремально — seek:
```kotlin
internal data class SyncDecision(
val speed: Float = 1f, // желаемая скорость аудио
val seekToMs: Long? = null, // только при большом рассинхроне (> HARD_SEEK_THRESHOLD)
val playing: Boolean? = null,
)
```
Правила (пороги — константами, из названий ясны):
- `abs(diff) <= SOFT_DRIFT_MS` (например 150мс) → связь «в норме»: `speed = 1f`.
- `audioPos < videoPos` (звук отстаёт) → подгонять **вперёд**: `speed = 1f + DRIFT_RATE` (например `1.02`).
- `audioPos > videoPos` (звук впереди) → подгонять **назад**: `speed = 1f - DRIFT_RATE` (например `0.98`).
- `abs(diff) > HARD_SEEK_THRESHOLD` (например 2000ms) → **`seekToMs = videoPos`** (рассинхрон большой, скорость не нагонит вовремя) — но это редкость.
- `playing` — как раньше (догон play/pause по состоянию мастера).
Учесть гистерезис: не менять направление скорости слишком часто. Например, помнить последнее направление и не переключать на «назад/вперёд» чаще чем раз в N (или не переключать, пока diff не станет > небольшой зоны около 0). Мин. простой вариант: **TRIGGER_BAND_MS** — скорость меняется/выравнивается только когда diff за пределами зоны `[-TRIGGER_BAND, +TRIGGER_BAND]` вокруг 0 (например 150ms); внутри зоны — `1f`. Это убирает дребезг.
### 2. Применение в `AudioSyncPlayer.sync()`
- Вызов `decideSync` с новым набором параметров.
- Если `decision.seekToMs != null` — текущий `seekTo` (редкий случай, большой рассинхрон) + восстановить `speed=1f` после seek (seek сбрасывает позицию, дальше мягко).
- Иначе (обычный путь): `player.setPlaybackParameters(PlaybackParameters(speed))` — **без `seekTo`**.
- Убрать «догонку через 1с» (второй резкий seek) — при мягкой коррекции она не нужна и вредна.
- `lastVideoPositionMs`, `lastVideoPlaying` — сохранить семантику из текущего.
Требование: **все операции ExoPlayer по-прежнему на main-потоке** (уже есть `main.post`). `setPlaybackParameters` вызывать внутри `main.post`.
### 3. Логирование
- В `sync` логировать теперь скорость: `audio sync: video=N audio=M diff=K speed=S` (S — применяемая скорость или 1f). Полезно для QA.
- При seek (редкий) — как сейчас `audio sync: seek → N`.
### 4. Тесты (обязательно)
Переписать/добавить unit-тесты на новую `decideSync` (и/или rename в `decideSpeed`/`decideAdjustment`):
- `audioPos` близко к `videoPos` (diff < порога мягкой зоны) → `speed == 1f`, `seekToMs == null`;
- `audioPos` отстаёт (audio < video, диф в зоне до HARD) → `speed > 1f`, без seek;
- `audioPos` впереди (audio > video, в зоне до HARD) → `speed < 1f`, без seek;
- `abs(diff) > HARD_SEEK_THRESHOLD` → `seekToMs == videoPos`, speed по-прежнему 1f (после seek мягко подогнать);
- `playing` (play/pause) отдельно — без влияния на speed;
- boundary: ровно на границах порога (equals) — чёткое поведение.
Проверить, что старые тесты `AudioSyncPlayerTest` переписаны под новый контракт (скорость вместо seek в обычных случаях).
### 5. Не трогать фильмы/прошлое поведение
- На очках ничего менять (мастер позиции остаётся).
- `play(url, startPositionMs)` — оставить: начальная позиция аудио при старте — `startPositionMs`, потом мягкая подгонка.
- Суть: только `_phone_`/`AudioSyncPlayer` меняется.
## Файлы
- `app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt` (основной).
- `app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt` (тесты).
## Требования к исполнению / команды
- Сборка и прогон тестов только через **локальный gradle** в репо (не трогать чужой infra).
- Прогнать: `./gradlew :app-phone:testDebugUnitTest` (или `:app-phone:test` в исходном тесте — уточнить task по проекту) — тесты модуля phone проходят.
- Код только на **Kotlin**, в стиле проекта (см. соседние файлы). Без лишних зависимостей.
- НЕ коммитить сами — собрать и прогнать тесты, показать диф. (Коммит — после подтверждения здоров: образец ты решаешь/проверяешь сам.)
@@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -20,34 +21,50 @@ import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File import java.io.File
import kotlin.math.abs import kotlin.math.abs
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */ /** Решение одного sync-цикла: speed — желаемая скорость аудио; seekToMs не null → редкий жёсткий seek; playing не null → play/pause. */
internal data class SyncDecision( internal data class SyncDecision(
val speed: Float = 1f,
val seekToMs: Long? = null, val seekToMs: Long? = null,
val playing: Boolean? = null, val playing: Boolean? = null,
) )
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */ /** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
internal const val SYNC_THRESHOLD_MS = 1000L 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): * Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |audio - video| > [thresholdMs] → перемотать на позицию очков; * - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
* - isPlaying очков отличается от состояния аудио → play/pause. * - 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( internal fun decideSync(
audioPosition: Long, audioPosition: Long,
videoPosition: Long, videoPosition: Long,
audioPlaying: Boolean, audioPlaying: Boolean,
videoPlaying: Boolean, videoPlaying: Boolean,
thresholdMs: Long = SYNC_THRESHOLD_MS,
): SyncDecision { ): 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 { val playing = when {
videoPlaying == audioPlaying -> null videoPlaying == audioPlaying -> null
videoPlaying -> true videoPlaying -> true
else -> false 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 player: ExoPlayer? = null
private var ticker: Job? = null private var ticker: Job? = null
/** Позиция очков из последнего sync (для догонки после неточного seekTo). */ /** Позиция очков из последнего sync. */
private var lastVideoPositionMs = 0L private var lastVideoPositionMs = 0L
/** IsPlaying очков из последнего sync. */ /** IsPlaying очков из последнего sync. */
private var lastVideoPlaying = false 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) { fun sync(positionMs: Long, isPlaying: Boolean) {
main.post { main.post {
@@ -160,22 +179,18 @@ class AudioSyncPlayer(private val context: Context) {
lastVideoPlaying = isPlaying lastVideoPlaying = isPlaying
val audioPos = p.currentPosition val audioPos = p.currentPosition
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying) val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying)
decision.seekToMs?.let { when {
p.seekTo(it) decision.seekToMs != null -> {
lastVideoPositionMs = it p.seekTo(decision.seekToMs)
// Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с), p.setPlaybackParameters(PlaybackParameters(1f))
// второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с). log("audio sync:", "seek → ${decision.seekToMs}")
main.postDelayed({ }
val pp = player ?: return@postDelayed else -> {
val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду p.setPlaybackParameters(PlaybackParameters(decision.speed))
if (abs(pp.currentPosition - target) > 500) { log("audio sync:", "video=$positionMs audio=$audioPos diff=${abs(audioPos - positionMs)} speed=${decision.speed}")
pp.seekTo(target) }
log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})")
}
}, 1_000)
} }
decision.playing?.let { if (it) p.play() else p.pause() } 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 { class AudioSyncPlayerTest {
@Test // --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek ---
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 совпадают — не трогаем")
}
@Test @Test
fun thresholdBoundaryDiff1000DoesNotSeek() { fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true) val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем") assertEquals(1f, d.speed, "полное совпадение — темп не трогаем")
} assertNull(d.seekToMs)
@Test
fun seeksToVideoPositionWhenDiffAboveThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
assertNull(d.playing) assertNull(d.playing)
} }
@Test @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) val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
assertEquals(20_000L, d.seekToMs) 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 @Test
fun playWhenVideoPlayingButAudioPaused() { fun playWhenVideoPlayingButAudioPaused() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true) 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, "очки играют — аудио должно играть") assertEquals(true, d.playing, "очки играют — аудио должно играть")
assertNull(d.seekToMs)
} }
@Test @Test
fun pauseWhenVideoPausedButAudioPlaying() { fun pauseWhenVideoPausedButAudioPlaying() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false) val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
assertNull(d.seekToMs) assertEquals(1f, d.speed)
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться") assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
assertNull(d.seekToMs)
} }
@Test @Test
fun seekAndPlayApplyTogether() { fun seekAndPlayApplyTogether() {
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true) val d = decideSync(audioPosition = 5_000, videoPosition = 15_000, audioPlaying = false, videoPlaying = true)
assertEquals(8_000L, d.seekToMs) assertEquals(15_000L, d.seekToMs)
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync") assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
} assertEquals(1f, d.speed)
@Test
fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs)
assertNull(d.playing)
} }
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») --- // --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---