Files
view-mate/TASK-soft-sync-rate.md
T
subochev 62d2f06797 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).
2026-08-27 23:23:50 +03:00

7.5 KiB
Raw Blame History

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