diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/FixedFrameRateEstimator.java b/library/core/src/main/java/com/google/android/exoplayer2/video/FixedFrameRateEstimator.java new file mode 100644 index 0000000000..502729f018 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/FixedFrameRateEstimator.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.video; + +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import java.util.Arrays; + +/** + * Attempts to detect and refine a fixed frame rate estimate based on frame presentation timestamps. + */ +/* package */ final class FixedFrameRateEstimator { + + /** + * The number of consecutive matching frame durations for the tracker to be considered in sync. + */ + @VisibleForTesting static final int CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC = 10; + /** + * The maximum amount frame durations can differ for them to be considered matching, in + * nanoseconds. + * + *

This constant is set to 1ms to account for container formats that only represent frame + * presentation timestamps to the nearest millisecond. In such cases, frame durations need to + * switch between values that are 1ms apart to achieve common fixed frame rates (e.g., 30fps + * content will need frames that are 33ms and 34ms). + */ + @VisibleForTesting static final long MAX_MATCHING_FRAME_DIFFERENCE_NS = 1_000_000; + + private Matcher currentMatcher; + private Matcher candidateMatcher; + private boolean candidateMatcherActive; + private boolean switchToCandidateMatcherWhenSynced; + private float formatFrameRate; + private long lastFramePresentationTimeNs; + + public FixedFrameRateEstimator() { + currentMatcher = new Matcher(); + candidateMatcher = new Matcher(); + formatFrameRate = Format.NO_VALUE; + lastFramePresentationTimeNs = C.TIME_UNSET; + } + + /** Resets the estimator. */ + public void reset() { + currentMatcher.reset(); + candidateMatcher.reset(); + candidateMatcherActive = false; + formatFrameRate = Format.NO_VALUE; + lastFramePresentationTimeNs = C.TIME_UNSET; + } + + /** + * Called when the renderer's output format changes. + * + * @param formatFrameRate The format's frame rate, or {@link Format#NO_VALUE} if unknown. + */ + public void onFormatChanged(float formatFrameRate) { + // The format frame rate is only used to determine to what extent the estimator should be reset. + // Frame rate estimates are always calculated directly from frame presentation timestamps. + if (this.formatFrameRate != formatFrameRate) { + reset(); + } else { + // Keep the current matcher, but prefer to switch to a new matcher once synced even if the + // current one does not lose sync. This avoids an issue where the current matcher would + // continue to be used if a frame rate change has occurred that's too small to trigger sync + // loss (e.g., a change from 30fps to 29.97fps) and which is not represented in the format + // frame rates (e.g., because they're unset or only have integer precision). + switchToCandidateMatcherWhenSynced = true; + } + this.formatFrameRate = formatFrameRate; + } + + /** + * Called with each frame presentation timestamp. + * + * @param framePresentationTimeNs The frame presentation timestamp, in nanoseconds. + */ + public void onNextFrame(long framePresentationTimeNs) { + currentMatcher.onNextFrame(framePresentationTimeNs); + if (currentMatcher.isSynced() && !switchToCandidateMatcherWhenSynced) { + candidateMatcherActive = false; + } else if (lastFramePresentationTimeNs != C.TIME_UNSET) { + if (!candidateMatcherActive || candidateMatcher.isLastFrameOutlier()) { + // Reset the candidate with the last and current frame presentation timestamps, so that it + // will try and match against the duration of the previous frame. + candidateMatcher.reset(); + candidateMatcher.onNextFrame(lastFramePresentationTimeNs); + } + candidateMatcherActive = true; + candidateMatcher.onNextFrame(framePresentationTimeNs); + } + if (candidateMatcherActive && candidateMatcher.isSynced()) { + // The candidate matcher should be promoted to be the current matcher. The current matcher + // can be re-used as the next candidate matcher. + Matcher previousMatcher = currentMatcher; + currentMatcher = candidateMatcher; + candidateMatcher = previousMatcher; + candidateMatcherActive = false; + switchToCandidateMatcherWhenSynced = false; + } + lastFramePresentationTimeNs = framePresentationTimeNs; + } + + /** Returns whether the estimator has detected a fixed frame rate. */ + public boolean isSynced() { + return currentMatcher.isSynced(); + } + + /** + * The currently detected fixed frame duration estimate in nanoseconds, or {@link C#TIME_UNSET} if + * {@link #isSynced()} is {@code false}. Whilst synced, the estimate is refined each time {@link + * #onNextFrame} is called with a new frame presentation timestamp. + */ + public long getFrameDurationNs() { + return isSynced() ? currentMatcher.getFrameDurationNs() : C.TIME_UNSET; + } + + /** + * The currently detected fixed frame rate estimate, or {@link Format#NO_VALUE} if {@link + * #isSynced()} is {@code false}. Whilst synced, the estimate is refined each time {@link + * #onNextFrame} is called with a new frame presentation timestamp. + */ + public double getFrameRate() { + return isSynced() + ? (double) C.NANOS_PER_SECOND / currentMatcher.getFrameDurationNs() + : Format.NO_VALUE; + } + + /** Tries to match frame durations against the duration of the first frame it receives. */ + private static final class Matcher { + + private long firstFramePresentationTimeNs; + private long firstFrameDurationNs; + private long lastFramePresentationTimeNs; + private long frameCount; + + /** The total number of frames that have matched the frame duration being tracked. */ + private long matchingFrameCount; + /** The sum of the frame durations of all matching frames. */ + private long matchingFrameDurationSumNs; + /** Cyclic buffer of flags indicating whether the most recent frame durations were outliers. */ + private final boolean[] recentFrameOutlierFlags; + /** + * The number of recent frame durations that were outliers. Equal to the number of {@code true} + * values in {@link #recentFrameOutlierFlags}. + */ + private int recentFrameOutlierCount; + + public Matcher() { + recentFrameOutlierFlags = new boolean[CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC]; + } + + public void reset() { + frameCount = 0; + matchingFrameCount = 0; + matchingFrameDurationSumNs = 0; + recentFrameOutlierCount = 0; + Arrays.fill(recentFrameOutlierFlags, false); + } + + public boolean isSynced() { + return frameCount > CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC + && recentFrameOutlierCount == 0; + } + + public boolean isLastFrameOutlier() { + if (frameCount == 0) { + return false; + } + return recentFrameOutlierFlags[getRecentFrameOutlierIndex(frameCount - 1)]; + } + + public long getFrameDurationNs() { + return matchingFrameCount == 0 ? 0 : (matchingFrameDurationSumNs / matchingFrameCount); + } + + public void onNextFrame(long framePresentationTimeNs) { + if (frameCount == 0) { + firstFramePresentationTimeNs = framePresentationTimeNs; + } else if (frameCount == 1) { + // This is the frame duration that the tracker will match against. + firstFrameDurationNs = framePresentationTimeNs - firstFramePresentationTimeNs; + matchingFrameDurationSumNs = firstFrameDurationNs; + matchingFrameCount = 1; + } else { + long lastFrameDurationNs = framePresentationTimeNs - lastFramePresentationTimeNs; + int recentFrameOutlierIndex = getRecentFrameOutlierIndex(frameCount); + if (Math.abs(lastFrameDurationNs - firstFrameDurationNs) + <= MAX_MATCHING_FRAME_DIFFERENCE_NS) { + matchingFrameCount++; + matchingFrameDurationSumNs += lastFrameDurationNs; + if (recentFrameOutlierFlags[recentFrameOutlierIndex]) { + recentFrameOutlierFlags[recentFrameOutlierIndex] = false; + recentFrameOutlierCount--; + } + } else { + if (!recentFrameOutlierFlags[recentFrameOutlierIndex]) { + recentFrameOutlierFlags[recentFrameOutlierIndex] = true; + recentFrameOutlierCount++; + } + } + } + + frameCount++; + lastFramePresentationTimeNs = framePresentationTimeNs; + } + + private static int getRecentFrameOutlierIndex(long frameCount) { + return (int) (frameCount % CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC); + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java index 02fc049441..bf8c133b23 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java @@ -55,24 +55,18 @@ public final class VideoFrameReleaseTimeHelper { */ private static final long VSYNC_OFFSET_PERCENTAGE = 80; - private static final int MIN_FRAMES_FOR_ADJUSTMENT = 6; - + private final FixedFrameRateEstimator fixedFrameRateEstimator; @Nullable private final WindowManager windowManager; @Nullable private final VSyncSampler vsyncSampler; @Nullable private final DefaultDisplayListener displayListener; private float formatFrameRate; private double playbackSpeed; - private long nextFramePresentationTimeUs; private long vsyncDurationNs; private long vsyncOffsetNs; - private boolean haveSync; - private long syncReleaseTimeNs; - private long syncFramePresentationTimeNs; - private long frameCount; - + private long frameIndex; private long pendingLastAdjustedFrameIndex; private long pendingLastAdjustedReleaseTimeNs; private long lastAdjustedFrameIndex; @@ -93,6 +87,7 @@ public final class VideoFrameReleaseTimeHelper { * @param context A context from which information about the default display can be retrieved. */ public VideoFrameReleaseTimeHelper(@Nullable Context context) { + fixedFrameRateEstimator = new FixedFrameRateEstimator(); if (context != null) { context = context.getApplicationContext(); windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); @@ -115,7 +110,7 @@ public final class VideoFrameReleaseTimeHelper { /** Called when the renderer is enabled. */ @TargetApi(17) // displayListener is null if Util.SDK_INT < 17. public void onEnabled() { - haveSync = false; + fixedFrameRateEstimator.reset(); if (windowManager != null) { vsyncSampler.addObserver(); if (displayListener != null) { @@ -138,12 +133,12 @@ public final class VideoFrameReleaseTimeHelper { /** Called when the renderer is started. */ public void onStarted() { - haveSync = false; + resetAdjustment(); } /** Called when the renderer's position is reset. */ public void onPositionReset() { - haveSync = false; + resetAdjustment(); } /** @@ -154,7 +149,7 @@ public final class VideoFrameReleaseTimeHelper { */ public void onPlaybackSpeed(double playbackSpeed) { this.playbackSpeed = playbackSpeed; - haveSync = false; + resetAdjustment(); } /** @@ -164,6 +159,7 @@ public final class VideoFrameReleaseTimeHelper { */ public void onFormatChanged(float formatFrameRate) { this.formatFrameRate = formatFrameRate; + fixedFrameRateEstimator.onFormatChanged(formatFrameRate); } /** @@ -172,14 +168,17 @@ public final class VideoFrameReleaseTimeHelper { * @param framePresentationTimeUs The frame presentation timestamp, in microseconds. */ public void onNextFrame(long framePresentationTimeUs) { - lastAdjustedFrameIndex = pendingLastAdjustedFrameIndex; - lastAdjustedReleaseTimeNs = pendingLastAdjustedReleaseTimeNs; - nextFramePresentationTimeUs = framePresentationTimeUs; - frameCount++; + if (pendingLastAdjustedFrameIndex != C.INDEX_UNSET) { + lastAdjustedFrameIndex = pendingLastAdjustedFrameIndex; + lastAdjustedReleaseTimeNs = pendingLastAdjustedReleaseTimeNs; + } + fixedFrameRateEstimator.onNextFrame(framePresentationTimeUs * 1000); + frameIndex++; } /** Returns the estimated playback frame rate, or {@link C#RATE_UNSET} if unknown. */ public float getPlaybackFrameRate() { + // TODO: Hook up fixedFrameRateEstimator. return formatFrameRate == Format.NO_VALUE ? C.RATE_UNSET : (float) (formatFrameRate * playbackSpeed); @@ -200,51 +199,21 @@ public final class VideoFrameReleaseTimeHelper { * {@link System#nanoTime()}. */ public long adjustReleaseTime(long releaseTimeNs) { - long framePresentationTimeNs = nextFramePresentationTimeUs * 1000; - // Until we know better, the adjustment will be a no-op. long adjustedReleaseTimeNs = releaseTimeNs; - if (haveSync) { - if (frameCount >= MIN_FRAMES_FOR_ADJUSTMENT) { - // We're synced and have waited the required number of frames to apply an adjustment. - // Calculate the average frame time across all the frames we've seen since the last sync. - // This will typically give us a frame rate at a finer granularity than the frame times - // themselves (which often only have millisecond granularity). - long averageFrameDurationNs = (framePresentationTimeNs - syncFramePresentationTimeNs) - / frameCount; - // Project the adjusted frame time forward using the average. - long candidateAdjustedReleaseTimeNs = - lastAdjustedReleaseTimeNs - + getPlayoutDuration( - averageFrameDurationNs * (frameCount - lastAdjustedFrameIndex)); - - if (adjustmentAllowed(releaseTimeNs, candidateAdjustedReleaseTimeNs)) { - adjustedReleaseTimeNs = candidateAdjustedReleaseTimeNs; - } else { - haveSync = false; - } + if (lastAdjustedFrameIndex != C.INDEX_UNSET && fixedFrameRateEstimator.isSynced()) { + long frameDurationNs = fixedFrameRateEstimator.getFrameDurationNs(); + long candidateAdjustedReleaseTimeNs = + lastAdjustedReleaseTimeNs + + getPlayoutDuration(frameDurationNs * (frameIndex - lastAdjustedFrameIndex)); + if (adjustmentAllowed(releaseTimeNs, candidateAdjustedReleaseTimeNs)) { + adjustedReleaseTimeNs = candidateAdjustedReleaseTimeNs; } else { - // We're synced but haven't waited the required number of frames to apply an adjustment. - // Check for drift between the proposed and projected frame release timestamps. - long projectedReleaseTimeNs = - syncReleaseTimeNs - + getPlayoutDuration(framePresentationTimeNs - syncFramePresentationTimeNs); - if (!adjustmentAllowed(releaseTimeNs, projectedReleaseTimeNs)) { - haveSync = false; - } + resetAdjustment(); } } - - // If we need to sync, do so now. - if (!haveSync) { - syncFramePresentationTimeNs = framePresentationTimeNs; - syncReleaseTimeNs = releaseTimeNs; - frameCount = 0; - haveSync = true; - } - - pendingLastAdjustedFrameIndex = frameCount; + pendingLastAdjustedFrameIndex = frameIndex; pendingLastAdjustedReleaseTimeNs = adjustedReleaseTimeNs; if (vsyncSampler == null || vsyncDurationNs == C.TIME_UNSET) { @@ -254,13 +223,18 @@ public final class VideoFrameReleaseTimeHelper { if (sampledVsyncTimeNs == C.TIME_UNSET) { return adjustedReleaseTimeNs; } - // Find the timestamp of the closest vsync. This is the vsync that we're targeting. long snappedTimeNs = closestVsync(adjustedReleaseTimeNs, sampledVsyncTimeNs, vsyncDurationNs); // Apply an offset so that we release before the target vsync, but after the previous one. return snappedTimeNs - vsyncOffsetNs; } + private void resetAdjustment() { + frameIndex = 0; + lastAdjustedFrameIndex = C.INDEX_UNSET; + pendingLastAdjustedFrameIndex = C.INDEX_UNSET; + } + @RequiresApi(17) private DefaultDisplayListener maybeBuildDefaultDisplayListenerV17(Context context) { DisplayManager manager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/FixedFrameRateEstimatorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/FixedFrameRateEstimatorTest.java new file mode 100644 index 0000000000..cae24a8375 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/FixedFrameRateEstimatorTest.java @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.video; + +import static com.google.android.exoplayer2.video.FixedFrameRateEstimator.CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC; +import static com.google.android.exoplayer2.video.FixedFrameRateEstimator.MAX_MATCHING_FRAME_DIFFERENCE_NS; +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link FixedFrameRateEstimator}. */ +@RunWith(AndroidJUnit4.class) +public final class FixedFrameRateEstimatorTest { + + @Test + public void fixedFrameRate_withSingleOutlier_syncsAndResyncs() { + long frameDurationNs = 33_333_333; + FixedFrameRateEstimator estimator = new FixedFrameRateEstimator(); + + // Initial frame. + long framePresentationTimestampNs = 0; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + + // Frames with consistent durations, working toward establishing sync. + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC - 1; i++) { + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + } + + // This frame should establish sync. + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(frameDurationNs); + + framePresentationTimestampNs += frameDurationNs; + // Make the frame duration just shorter enough to lose sync. + framePresentationTimestampNs -= MAX_MATCHING_FRAME_DIFFERENCE_NS + 1; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + + // Frames with consistent durations, working toward re-establishing sync. + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC - 1; i++) { + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + } + + // This frame should re-establish sync. + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(frameDurationNs); + } + + @Test + public void fixedFrameRate_withOutlierFirstFrameDuration_syncs() { + long frameDurationNs = 33_333_333; + FixedFrameRateEstimator estimator = new FixedFrameRateEstimator(); + + // Initial frame with double duration. + long framePresentationTimestampNs = 0; + estimator.onNextFrame(framePresentationTimestampNs); + + framePresentationTimestampNs += frameDurationNs * 2; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + + // Frames with consistent durations, working toward establishing sync. + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC - 1; i++) { + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + } + + // This frame should establish sync. + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + } + + @Test + public void newFixedFrameRate_resyncs() { + long frameDurationNs = 33_333_333; + FixedFrameRateEstimator estimator = new FixedFrameRateEstimator(); + + long framePresentationTimestampNs = 0; + estimator.onNextFrame(framePresentationTimestampNs); + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC; i++) { + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + } + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(frameDurationNs); + + // Frames durations are halved from this point. + long halfFrameRateDuration = frameDurationNs / 2; + + // Frames with consistent durations, working toward establishing new sync. + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC - 1; i++) { + framePresentationTimestampNs += halfFrameRateDuration; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + } + + // This frame should establish sync. + framePresentationTimestampNs += halfFrameRateDuration; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(halfFrameRateDuration); + } + + @Test + public void fixedFrameRate_withMillisecondPrecision_syncs() { + long frameDurationNs = 33_333_333; + FixedFrameRateEstimator estimator = new FixedFrameRateEstimator(); + + // Initial frame. + long framePresentationTimestampNs = 0; + estimator.onNextFrame(getNsWithMsPrecision(framePresentationTimestampNs)); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + + // Frames with consistent durations, working toward establishing sync. + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC - 1; i++) { + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(getNsWithMsPrecision(framePresentationTimestampNs)); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + } + + // This frame should establish sync. + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(getNsWithMsPrecision(framePresentationTimestampNs)); + + assertThat(estimator.isSynced()).isTrue(); + // The estimated frame duration should be strictly better than millisecond precision. + long estimatedFrameDurationNs = estimator.getFrameDurationNs(); + long estimatedFrameDurationErrorNs = Math.abs(estimatedFrameDurationNs - frameDurationNs); + assertThat(estimatedFrameDurationErrorNs).isLessThan(1000000); + } + + @Test + public void variableFrameRate_doesNotSync() { + long frameDurationNs = 33_333_333; + FixedFrameRateEstimator estimator = new FixedFrameRateEstimator(); + + // Initial frame. + long framePresentationTimestampNs = 0; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC * 10; i++) { + framePresentationTimestampNs += frameDurationNs; + // Adjust a frame that's just different enough, just often enough to prevent sync. + if ((i % CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC) == 0) { + framePresentationTimestampNs += MAX_MATCHING_FRAME_DIFFERENCE_NS + 1; + } + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + } + } + + @Test + public void newFixedFrameRate_withFormatFrameRateChange_resyncs() { + long frameDurationNs = 33_333_333; + float frameRate = (float) C.NANOS_PER_SECOND / frameDurationNs; + FixedFrameRateEstimator estimator = new FixedFrameRateEstimator(); + estimator.onFormatChanged(frameRate); + + long framePresentationTimestampNs = 0; + estimator.onNextFrame(framePresentationTimestampNs); + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC; i++) { + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + } + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(frameDurationNs); + + // Frames durations are halved from this point. + long halfFrameDuration = frameDurationNs * 2; + float doubleFrameRate = (float) C.NANOS_PER_SECOND / halfFrameDuration; + estimator.onFormatChanged(doubleFrameRate); + + // Format frame rate change should cause immediate sync loss. + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + + // Frames with consistent durations, working toward establishing new sync. + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC; i++) { + framePresentationTimestampNs += halfFrameDuration; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isFalse(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(C.TIME_UNSET); + } + + // This frame should establish sync. + framePresentationTimestampNs += halfFrameDuration; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(halfFrameDuration); + } + + @Test + public void smallFrameRateChange_withoutFormatFrameRateChange_keepsSyncAndAdjustsEstimate() { + long frameDurationNs = 33_333_333; // 30 fps + float roundedFrameRate = 30; + FixedFrameRateEstimator estimator = new FixedFrameRateEstimator(); + estimator.onFormatChanged(roundedFrameRate); + + long framePresentationTimestampNs = 0; + estimator.onNextFrame(framePresentationTimestampNs); + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC; i++) { + framePresentationTimestampNs += frameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + } + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(frameDurationNs); + + long newFrameDurationNs = 33_366_667; // 30 * (1000/1001) = 29.97 fps + estimator.onFormatChanged(roundedFrameRate); // Format frame rate is unchanged. + + // Previous estimate should remain valid for now because neither format specified a duration. + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(frameDurationNs); + + // The estimate should start moving toward the new frame duration. If should not lose sync + // because the change in frame rate is very small. + for (int i = 0; i < CONSECUTIVE_MATCHING_FRAME_DURATIONS_FOR_SYNC - 1; i++) { + framePresentationTimestampNs += newFrameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isGreaterThan(frameDurationNs); + assertThat(estimator.getFrameDurationNs()).isLessThan(newFrameDurationNs); + } + + framePresentationTimestampNs += newFrameDurationNs; + estimator.onNextFrame(framePresentationTimestampNs); + + // Frames with the previous frame duration should now be excluded from the estimate, so the + // estimate should become exact. + assertThat(estimator.isSynced()).isTrue(); + assertThat(estimator.getFrameDurationNs()).isEqualTo(newFrameDurationNs); + } + + private static final long getNsWithMsPrecision(long presentationTimeNs) { + return (presentationTimeNs / 1000000) * 1000000; + } +}