From 62d2f06797357c05bb8cfcc302e74e3093592a49 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 27 Aug 2026 23:23:50 +0300 Subject: [PATCH] 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). --- TASK-soft-sync-rate.md | 84 +++++++++++++++++ .../binom/viewmate/phone/AudioSyncPlayer.kt | 65 ++++++++----- .../viewmate/phone/AudioSyncPlayerTest.kt | 94 +++++++++++++------ 3 files changed, 191 insertions(+), 52 deletions(-) create mode 100644 TASK-soft-sync-rate.md diff --git a/TASK-soft-sync-rate.md b/TASK-soft-sync-rate.md new file mode 100644 index 0000000..2ff5b9a --- /dev/null +++ b/TASK-soft-sync-rate.md @@ -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**, в стиле проекта (см. соседние файлы). Без лишних зависимостей. +- НЕ коммитить сами — собрать и прогнать тесты, показать диф. (Коммит — после подтверждения здоров: образец ты решаешь/проверяешь сам.) \ No newline at end of file diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt index cb08754..ae45e77 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt @@ -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)}") } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt index 9af2078..d4ae085 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt @@ -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 (чистая функция, ветки причины «нет звука») ---