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