# 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**, в стиле проекта (см. соседние файлы). Без лишних зависимостей. - НЕ коммитить сами — собрать и прогнать тесты, показать диф. (Коммит — после подтверждения здоров: образец ты решаешь/проверяешь сам.)