diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java index 9d85a9e364..8f5b18550f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java @@ -26,7 +26,9 @@ import android.media.AudioTrack; import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.media3.common.C; +import androidx.media3.common.util.Clock; import androidx.media3.common.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -201,6 +203,19 @@ import java.lang.reflect.Method; private long previousModePositionUs; private long previousModeSystemTimeUs; + /** + * Whether to expect a raw playback head reset. + * + *

When an {@link AudioTrack} is reused during offloaded playback, rawPlaybackHeadPosition is + * reset upon track transition. {@link AudioTrackPositionTracker} must be notified of the + * impending reset and keep track of total accumulated rawPlaybackHeadPosition. + */ + private boolean expectRawPlaybackHeadReset; + + private long sumRawPlaybackHeadPosition; + + private Clock clock = Clock.DEFAULT; + /** * Creates a new audio track position tracker. * @@ -245,6 +260,8 @@ import java.lang.reflect.Method; bufferSizeUs = isOutputPcm ? framesToDurationUs(bufferSize / outputPcmFrameSize) : C.TIME_UNSET; rawPlaybackHeadPosition = 0; rawPlaybackHeadWrapCount = 0; + expectRawPlaybackHeadReset = false; + sumRawPlaybackHeadPosition = 0; passthroughWorkaroundPauseOffset = 0; hasData = false; stopTimestampUs = C.TIME_UNSET; @@ -271,7 +288,7 @@ import java.lang.reflect.Method; // If the device supports it, use the playback timestamp from AudioTrack.getTimestamp. // Otherwise, derive a smoothed position by sampling the track's frame position. - long systemTimeUs = System.nanoTime() / 1000; + long systemTimeUs = clock.nanoTime() / 1000; long positionUs; AudioTimestampPoller audioTimestampPoller = checkNotNull(this.audioTimestampPoller); boolean useGetTimestampMode = audioTimestampPoller.hasAdvancingTimestamp(); @@ -445,6 +462,14 @@ import java.lang.reflect.Method; return false; } + /** + * Sets up the position tracker to expect a reset in raw playback head position due to reusing an + * {@link AudioTrack} and an impending track transition. + */ + public void expectRawPlaybackHeadReset() { + expectRawPlaybackHeadReset = true; + } + /** * Resets the position tracker. Should be called when the audio track previously passed to {@link * #setAudioTrack(AudioTrack, boolean, int, int, int)} is no longer in use. @@ -455,8 +480,18 @@ import java.lang.reflect.Method; audioTimestampPoller = null; } + /** + * Set clock used for {@code nanoTime()} requests. + * + * @param clock The clock to be used for {@code nanoTime()} requests. + */ + @VisibleForTesting + /* package */ void setClock(Clock clock) { + this.clock = clock; + } + private void maybeSampleSyncParams() { - long systemTimeUs = System.nanoTime() / 1000; + long systemTimeUs = clock.nanoTime() / 1000; if (systemTimeUs - lastPlayheadSampleTimeUs >= MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US) { long playbackPositionUs = getPlaybackHeadPositionUs(); if (playbackPositionUs == 0) { @@ -608,7 +643,7 @@ import java.lang.reflect.Method; updateRawPlaybackHeadPosition(currentTimeMs); lastRawPlaybackHeadPositionSampleTimeMs = currentTimeMs; } - return rawPlaybackHeadPosition + (rawPlaybackHeadWrapCount << 32); + return rawPlaybackHeadPosition + sumRawPlaybackHeadPosition + (rawPlaybackHeadWrapCount << 32); } private void updateRawPlaybackHeadPosition(long currentTimeMs) { @@ -648,8 +683,13 @@ import java.lang.reflect.Method; } if (this.rawPlaybackHeadPosition > rawPlaybackHeadPosition) { - // The value must have wrapped around. - rawPlaybackHeadWrapCount++; + if (expectRawPlaybackHeadReset) { + sumRawPlaybackHeadPosition += this.rawPlaybackHeadPosition; + expectRawPlaybackHeadReset = false; + } else { + // The value must have wrapped around. + rawPlaybackHeadWrapCount++; + } } this.rawPlaybackHeadPosition = rawPlaybackHeadPosition; } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java index b226323093..2fc8554164 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java @@ -860,6 +860,7 @@ public final class DefaultAudioSink implements AudioSink { // not have started yet. Do not call setOffloadEndOfStream as it would throw. if (audioTrack.getPlayState() == AudioTrack.PLAYSTATE_PLAYING) { audioTrack.setOffloadEndOfStream(); + audioTrackPositionTracker.expectRawPlaybackHeadReset(); } audioTrack.setOffloadDelayPadding( configuration.inputFormat.encoderDelay, configuration.inputFormat.encoderPadding); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioTrackPositionTrackerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioTrackPositionTrackerTest.java new file mode 100644 index 0000000000..e2168012c0 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioTrackPositionTrackerTest.java @@ -0,0 +1,237 @@ +/* + * Copyright 2023 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 androidx.media3.exoplayer.audio; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.media.AudioFormat; +import android.media.AudioTrack; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.util.Util; +import androidx.media3.test.utils.FakeClock; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link AudioTrackPositionTracker}. */ +@RunWith(AndroidJUnit4.class) +public class AudioTrackPositionTrackerTest { + private static final android.media.AudioAttributes AUDIO_ATTRIBUTES = + AudioAttributes.DEFAULT.getAudioAttributesV21().audioAttributes; + private static final int BYTES_PER_FRAME_16_BIT = 2; + private static final int CHANNEL_COUNT_STEREO = 2; + private static final int OUTPUT_PCM_FRAME_SIZE = + Util.getPcmFrameSize(C.ENCODING_PCM_16BIT, CHANNEL_COUNT_STEREO); + private static final int SAMPLE_RATE = 44100; + private static final long START_TIME_MS = 9999L; + private static final long TIME_TO_ADVANCE_MS = 1000L; + private static final int MIN_BUFFER_SIZE = + AudioTrack.getMinBufferSize( + SAMPLE_RATE, Util.getAudioTrackChannelConfig(CHANNEL_COUNT_STEREO), C.ENCODING_PCM_16BIT); + private static final AudioFormat AUDIO_FORMAT = + Util.getAudioFormat( + SAMPLE_RATE, Util.getAudioTrackChannelConfig(CHANNEL_COUNT_STEREO), C.ENCODING_PCM_16BIT); + + private final AudioTrackPositionTracker audioTrackPositionTracker = + new AudioTrackPositionTracker(mock(AudioTrackPositionTracker.Listener.class)); + private final AudioTrack audioTrack = createDefaultAudioTrack(); + private final FakeClock clock = + new FakeClock(/* initialTimeMs= */ START_TIME_MS, /* isAutoAdvancing= */ true); + + @Before + public void setUp() { + audioTrackPositionTracker.setClock(clock); + } + + @Test + public void + getCurrentPositionUs_withoutExpectRawPlaybackHeadReset_returnsPositionWithWrappedValue() { + audioTrackPositionTracker.setAudioTrack( + audioTrack, + /* isPassthrough= */ false, + C.ENCODING_PCM_16BIT, + OUTPUT_PCM_FRAME_SIZE, + MIN_BUFFER_SIZE); + audioTrackPositionTracker.start(); + audioTrack.play(); + // Advance and write to audio track at least twice to move rawHeadPosition past wrap point. + for (int i = 0; i < 2; i++) { + advanceTimeAndWriteBytes(audioTrack); + audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false); + } + + // Reset audio track and write bytes to simulate position overflow. + audioTrack.flush(); + advanceTimeAndWriteBytes(audioTrack); + + assertThat(audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false)) + .isGreaterThan(4294967296L); + } + + @Test + public void getCurrentPositionUs_withExpectRawPlaybackHeadReset_returnsAccumulatedPosition() { + audioTrackPositionTracker.setAudioTrack( + audioTrack, + /* isPassthrough= */ false, + C.ENCODING_PCM_16BIT, + OUTPUT_PCM_FRAME_SIZE, + MIN_BUFFER_SIZE); + audioTrackPositionTracker.start(); + audioTrack.play(); + // Advance and write to audio track at least twice to move rawHeadPosition past wrap point. + for (int i = 0; i < 2; i++) { + advanceTimeAndWriteBytes(audioTrack); + audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false); + } + + // Reset audio track to simulate track reuse and transition. + // Set tracker to expect playback head reset. + audioTrack.flush(); + audioTrackPositionTracker.expectRawPlaybackHeadReset(); + advanceTimeAndWriteBytes(audioTrack); + + // Expected position is msToUs(# of writes)*TIME_TO_ADVANCE_MS. + assertThat(audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false)) + .isEqualTo(3000000L); + } + + @Test + public void getCurrentPositionUs_withExpectPositionResetThenPause_returnsCorrectPosition() { + audioTrackPositionTracker.setAudioTrack( + audioTrack, + /* isPassthrough= */ false, + C.ENCODING_PCM_16BIT, + OUTPUT_PCM_FRAME_SIZE, + MIN_BUFFER_SIZE); + audioTrackPositionTracker.start(); + audioTrack.play(); + // Advance and write to audio track at least twice to move rawHeadPosition past wrap point. + for (int i = 0; i < 2; i++) { + advanceTimeAndWriteBytes(audioTrack); + audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false); + } + // Reset audio track to simulate track transition and set tracker to expect playback head reset. + audioTrack.flush(); + audioTrackPositionTracker.expectRawPlaybackHeadReset(); + advanceTimeAndWriteBytes(audioTrack); + assertThat(audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false)) + .isEqualTo(3000000L); + + // Pause tracker, pause audio track, and advance time to test that position does not change + // during pause + audioTrackPositionTracker.pause(); + audioTrack.pause(); + clock.advanceTime(TIME_TO_ADVANCE_MS); + + // Expected position is msToUs(# of writes)*TIME_TO_ADVANCE_MS. + assertThat(audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false)) + .isEqualTo(3000000L); + } + + @Test + public void getCurrentPositionUs_withSetAudioTrackResettingSumPosition_returnsCorrectPosition() { + AudioTrack audioTrack1 = createDefaultAudioTrack(); + AudioTrack audioTrack2 = createDefaultAudioTrack(); + audioTrackPositionTracker.setAudioTrack( + audioTrack1, + /* isPassthrough= */ false, + C.ENCODING_PCM_16BIT, + OUTPUT_PCM_FRAME_SIZE, + MIN_BUFFER_SIZE); + audioTrackPositionTracker.start(); + audioTrack1.play(); + // Advance and write to audio track at least twice to move rawHeadPosition past wrap point. + for (int i = 0; i < 2; i++) { + advanceTimeAndWriteBytes(audioTrack1); + audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false); + } + // Reset audio track and set tracker to expect playback head reset to simulate track transition. + audioTrack1.flush(); + audioTrackPositionTracker.expectRawPlaybackHeadReset(); + advanceTimeAndWriteBytes(audioTrack1); + // Test for correct setup with current position being accumulated position. + assertThat(audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false)) + .isEqualTo(3000000L); + + // Set new audio track and reset position tracker to simulate transition to new AudioTrack. + audioTrackPositionTracker.reset(); + audioTrackPositionTracker.setAudioTrack( + audioTrack2, + /* isPassthrough= */ false, + C.ENCODING_PCM_16BIT, + OUTPUT_PCM_FRAME_SIZE, + MIN_BUFFER_SIZE); + audioTrack2.play(); + advanceTimeAndWriteBytes(audioTrack2); + + // Expected position is msToUs(1 write)*TIME_TO_ADVANCE_MS. + assertThat(audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false)) + .isEqualTo(1000000L); + } + + @Test + public void getCurrentPositionUs_withSetAudioTrackResetsExpectPosition_returnsWrappedValue() { + // Set tracker to expect playback head reset to simulate expected track transition. + audioTrackPositionTracker.expectRawPlaybackHeadReset(); + audioTrackPositionTracker.setAudioTrack( + audioTrack, + /* isPassthrough= */ false, + C.ENCODING_PCM_16BIT, + OUTPUT_PCM_FRAME_SIZE, + MIN_BUFFER_SIZE); + audioTrack.play(); + + // Advance and write to audio track at least twice to move rawHeadPosition past wrap point. + for (int i = 0; i < 2; i++) { + advanceTimeAndWriteBytes(audioTrack); + audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false); + } + // Reset audio track and write bytes to simulate position overflow. + audioTrack.flush(); + advanceTimeAndWriteBytes(audioTrack); + + assertThat(audioTrackPositionTracker.getCurrentPositionUs(/* sourceEnded= */ false)) + .isGreaterThan(4294967296L); + } + + private void advanceTimeAndWriteBytes(AudioTrack audioTrack) { + clock.advanceTime(TIME_TO_ADVANCE_MS); + ByteBuffer byteBuffer = createDefaultSilenceBuffer(); + int bytesRemaining = byteBuffer.remaining(); + audioTrack.write(byteBuffer, bytesRemaining, AudioTrack.WRITE_NON_BLOCKING); + } + + /** Creates a one second silence buffer for 44.1 kHz stereo 16-bit audio. */ + private static ByteBuffer createDefaultSilenceBuffer() { + return ByteBuffer.allocateDirect(SAMPLE_RATE * CHANNEL_COUNT_STEREO * BYTES_PER_FRAME_16_BIT) + .order(ByteOrder.nativeOrder()); + } + + /** Creates a basic {@link AudioTrack} with 16 bit PCM encoding type. */ + private static AudioTrack createDefaultAudioTrack() { + return new AudioTrack.Builder() + .setAudioAttributes(AUDIO_ATTRIBUTES) + .setAudioFormat(AUDIO_FORMAT) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(MIN_BUFFER_SIZE) + .build(); + } +}