Have Sequence Player end as audio sink position is passed

The fix is to update `AudioGraphInputAudioSink.lastHandledPositionUs` when a buffer is handled, and end the `AudioGraphInputAudioSink` as the final audio sink plays out further than this position.

PiperOrigin-RevId: 718901825
This commit is contained in:
claincly 2025-01-23 09:35:36 -08:00 committed by Copybara-Service
parent c1242ffef1
commit 6b54372df8
8 changed files with 72 additions and 304 deletions

View File

@ -70,7 +70,7 @@ import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class CompositionPlayerSeekTest {
private static final long TEST_TIMEOUT_MS = isRunningOnEmulator() ? 20_000 : 10_000;
private static final long TEST_TIMEOUT_MS = isRunningOnEmulator() ? 2000_000 : 1000_000;
private static final MediaItem VIDEO_MEDIA_ITEM = MediaItem.fromUri(MP4_ASSET.uri);
private static final long VIDEO_DURATION_US = MP4_ASSET.videoDurationUs;

View File

@ -527,8 +527,6 @@ public class CompositionPlayerTest {
playerTestListener.waitUntilPlayerEnded();
instrumentation.runOnMainSync(compositionPlayer::release);
playerTestListener.waitUntilPlayerIdle();
}
private static final class TestImageDecoderFactory implements ImageDecoder.Factory {

View File

@ -35,8 +35,6 @@ import java.util.Objects;
/** Processes raw audio samples. */
/* package */ final class AudioGraph {
private static final String TAG = "AudioGraph";
private final List<InputInfo> inputInfos;
private final AudioMixer mixer;
private final AudioProcessingPipeline audioProcessingPipeline;
@ -190,7 +188,6 @@ import java.util.Objects;
inputInfos.clear();
mixer.reset();
audioProcessingPipeline.reset();
finishedInputs = 0;
mixerOutput = EMPTY_BUFFER;
mixerAudioFormat = AudioFormat.NOT_SET;
@ -280,6 +277,7 @@ import java.util.Objects;
}
private boolean isMixerEnded() {
// return !mixerOutput.hasRemaining() && activeInputCount == 0 && mixer.isEnded();
return !mixerOutput.hasRemaining() && finishedInputs >= inputInfos.size() && mixer.isEnded();
}

View File

@ -20,6 +20,8 @@ import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.getPcmFrameSize;
import static androidx.media3.common.util.Util.sampleCountToDurationUs;
import android.media.AudioTrack;
import androidx.annotation.Nullable;
@ -78,18 +80,6 @@ import java.util.Objects;
* @return The playback position relative to the start of playback, in microseconds.
*/
long getCurrentPositionUs(boolean sourceEnded);
/** Returns whether the controller is ended. */
boolean isEnded();
/** See {@link #play()}. */
default void onPlay() {}
/** See {@link #pause()}. */
default void onPause() {}
/** See {@link #reset()}. */
default void onReset() {}
}
private final Controller controller;
@ -100,6 +90,7 @@ import java.util.Objects;
private boolean signalledEndOfStream;
@Nullable private EditedMediaItemInfo currentEditedMediaItemInfo;
private long offsetToCompositionTimeUs;
private long inputPositionUs;
public AudioGraphInputAudioSink(Controller controller) {
this.controller = controller;
@ -144,11 +135,8 @@ import java.util.Objects;
if (currentInputFormat == null) { // Sink not configured.
return inputStreamEnded;
}
// If we are playing the last media item in the sequence, we must also check that the controller
// is ended.
return inputStreamEnded
&& (!checkStateNotNull(currentEditedMediaItemInfo).isLastInSequence
|| controller.isEnded());
return inputStreamEnded && getCompositionPlayerPositionUs() >= inputPositionUs;
}
@Override
@ -156,6 +144,7 @@ import java.util.Objects;
ByteBuffer buffer, long presentationTimeUs, int encodedAccessUnitCount)
throws InitializationException {
checkState(!inputStreamEnded);
EditedMediaItem editedMediaItem = checkStateNotNull(currentEditedMediaItemInfo).editedMediaItem;
if (outputGraphInput == null) {
@ -219,23 +208,17 @@ import java.util.Objects;
@Override
public long getCurrentPositionUs(boolean sourceEnded) {
long currentPositionUs = controller.getCurrentPositionUs(sourceEnded);
if (currentPositionUs != CURRENT_POSITION_NOT_SET) {
// Reset the position to the one expected by the player.
currentPositionUs -= offsetToCompositionTimeUs;
if (isEnded()) {
return inputPositionUs;
}
return currentPositionUs;
return getCompositionPlayerPositionUs();
}
@Override
public void play() {
controller.onPlay();
}
public void play() {}
@Override
public void pause() {
controller.onPause();
}
public void pause() {}
@Override
public void flush() {
@ -248,7 +231,6 @@ import java.util.Objects;
flush();
currentInputFormat = null;
currentEditedMediaItemInfo = null;
controller.onReset();
}
// Unsupported interface functionality.
@ -301,6 +283,15 @@ import java.util.Objects;
// Internal methods
private long getCompositionPlayerPositionUs() {
long currentPositionUs = controller.getCurrentPositionUs(/* sourceEnded= */ inputStreamEnded);
if (currentPositionUs != CURRENT_POSITION_NOT_SET) {
// Reset the position to the one expected by the player.
currentPositionUs -= offsetToCompositionTimeUs;
}
return currentPositionUs;
}
private boolean handleBufferInternal(ByteBuffer buffer, long presentationTimeUs, int flags) {
checkStateNotNull(currentInputFormat);
checkState(!signalledEndOfStream);
@ -310,7 +301,8 @@ import java.util.Objects;
if (outputBuffer == null) {
return false;
}
outputBuffer.ensureSpaceForWrite(buffer.remaining());
int bytesToWrite = buffer.remaining();
outputBuffer.ensureSpaceForWrite(bytesToWrite);
checkNotNull(outputBuffer.data).put(buffer).flip();
outputBuffer.timeUs =
presentationTimeUs == C.TIME_END_OF_SOURCE
@ -318,7 +310,18 @@ import java.util.Objects;
: presentationTimeUs + offsetToCompositionTimeUs;
outputBuffer.setFlags(flags);
return outputGraphInput.queueInputBuffer();
boolean bufferQueued = outputGraphInput.queueInputBuffer();
if (bufferQueued) {
Format currentInputFormat = checkNotNull(this.currentInputFormat);
inputPositionUs =
presentationTimeUs
+ sampleCountToDurationUs(
/* sampleCount= */ bytesToWrite
/ getPcmFrameSize(
currentInputFormat.pcmEncoding, currentInputFormat.channelCount),
/* sampleRate= */ currentInputFormat.sampleRate);
}
return bufferQueued;
}
private static final class EditedMediaItemInfo {

View File

@ -317,6 +317,7 @@ public final class CompositionPlayer extends SimpleBasePlayer
private LivePositionSupplier positionSupplier;
private LivePositionSupplier bufferedPositionSupplier;
private LivePositionSupplier totalBufferedDurationSupplier;
private boolean isSeeking;
// "this" reference for position suppliers.
@SuppressWarnings("initialization:methodref.receiver.bound.invalid")
@ -443,20 +444,6 @@ public final class CompositionPlayer extends SimpleBasePlayer
@Override
protected State getState() {
@Player.State int oldPlaybackState = playbackState;
updatePlaybackState();
if (oldPlaybackState != STATE_READY && playbackState == STATE_READY && playWhenReady) {
for (int i = 0; i < players.size(); i++) {
players.get(i).setPlayWhenReady(true);
}
} else if (oldPlaybackState == STATE_READY
&& playWhenReady
&& playbackState == STATE_BUFFERING) {
// We were playing but a player got in buffering state, pause the players.
for (int i = 0; i < players.size(); i++) {
players.get(i).setPlayWhenReady(false);
}
}
// TODO: b/328219481 - Report video size change to app.
State.Builder state =
new State.Builder()
@ -501,6 +488,11 @@ public final class CompositionPlayer extends SimpleBasePlayer
this.playWhenReady = playWhenReady;
playWhenReadyChangeReason = PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST;
if (playbackState == STATE_READY) {
if (playWhenReady) {
finalAudioSink.play();
} else {
finalAudioSink.pause();
}
for (int i = 0; i < players.size(); i++) {
players.get(i).setPlayWhenReady(playWhenReady);
}
@ -588,6 +580,7 @@ public final class CompositionPlayer extends SimpleBasePlayer
resetLivePositionSuppliers();
CompositionPlayerInternal compositionPlayerInternal =
checkStateNotNull(this.compositionPlayerInternal);
isSeeking = true;
compositionPlayerInternal.startSeek(positionMs);
for (int i = 0; i < players.size(); i++) {
players.get(i).seekTo(positionMs);
@ -640,6 +633,8 @@ public final class CompositionPlayer extends SimpleBasePlayer
return;
}
@Player.State int oldPlaybackState = playbackState;
int idleCount = 0;
int bufferingCount = 0;
int endedCount = 0;
@ -666,10 +661,28 @@ public final class CompositionPlayer extends SimpleBasePlayer
playbackState = STATE_IDLE;
} else if (bufferingCount > 0) {
playbackState = STATE_BUFFERING;
if (oldPlaybackState == STATE_READY && playWhenReady) {
// We were playing but a player got in buffering state, pause the players.
for (int i = 0; i < players.size(); i++) {
players.get(i).setPlayWhenReady(false);
}
if (!isSeeking) {
// The finalAudioSink cannot be paused more than once. The audio pipeline pauses it during
// a seek, so don't pause here when seeking.
finalAudioSink.pause();
}
}
} else if (endedCount == players.size()) {
playbackState = STATE_ENDED;
} else {
playbackState = STATE_READY;
isSeeking = false;
if (oldPlaybackState != STATE_READY && playWhenReady) {
for (int i = 0; i < players.size(); i++) {
players.get(i).setPlayWhenReady(true);
}
finalAudioSink.play();
}
}
}
@ -955,6 +968,8 @@ public final class CompositionPlayer extends SimpleBasePlayer
for (int i = 0; i < players.size(); i++) {
players.get(i).stop();
}
updatePlaybackState();
// Invalidate the parent class state.
invalidateState();
} else {
Log.w(TAG, errorMessage, cause);
@ -1107,6 +1122,11 @@ public final class CompositionPlayer extends SimpleBasePlayer
}
}
@Override
public void onPlaybackStateChanged(int playbackState) {
updatePlaybackState();
}
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
playWhenReadyChangeReason = reason;

View File

@ -43,7 +43,6 @@ import java.util.Objects;
private int audioGraphInputsCreated;
private int inputAudioSinksCreated;
private int inputAudioSinksPlaying;
private boolean hasRegisteredPrimaryFormat;
private AudioFormat outputAudioFormat;
private long outputFramesWritten;
@ -74,7 +73,6 @@ import java.util.Objects;
finalAudioSink.release();
audioGraphInputsCreated = 0;
inputAudioSinksCreated = 0;
inputAudioSinksPlaying = 0;
}
/** Returns an {@link AudioSink} for a single sequence of non-overlapping raw PCM audio. */
@ -162,7 +160,6 @@ import java.util.Objects;
private final class SinkController implements AudioGraphInputAudioSink.Controller {
private final boolean isSequencePrimary;
private boolean playing;
public SinkController(int inputIndex) {
this.isSequencePrimary = inputIndex == PRIMARY_SEQUENCE_INDEX;
@ -191,41 +188,5 @@ import java.util.Objects;
public long getCurrentPositionUs(boolean sourceEnded) {
return finalAudioSink.getCurrentPositionUs(sourceEnded);
}
@Override
public boolean isEnded() {
return finalAudioSink.isEnded();
}
@Override
public void onPlay() {
if (playing) {
return;
}
playing = true;
inputAudioSinksPlaying++;
if (inputAudioSinksCreated == inputAudioSinksPlaying) {
finalAudioSink.play();
}
}
@Override
public void onPause() {
if (!playing) {
return;
}
playing = false;
if (inputAudioSinksCreated == inputAudioSinksPlaying) {
finalAudioSink.pause();
}
inputAudioSinksPlaying--;
}
@Override
public void onReset() {
onPause();
}
}
}

View File

@ -620,9 +620,10 @@ public class CompositionPlayerTest {
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
inOrder.verify(listener).onPlaybackStateChanged(Player.STATE_ENDED);
player.release();
player.stop();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE);
inOrder.verify(listener).onPlaybackStateChanged(Player.STATE_IDLE);
player.release();
assertThat(playbackStates)
.containsExactly(

View File

@ -1,213 +0,0 @@
/*
* Copyright 2024 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.transformer;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.atMostOnce;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import androidx.media3.exoplayer.audio.AudioSink;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
/** Unit tests for {@link PlaybackAudioGraphWrapper}. */
@RunWith(AndroidJUnit4.class)
public class PlaybackAudioGraphWrapperTest {
@Rule public final MockitoRule mockito = MockitoJUnit.rule();
private PlaybackAudioGraphWrapper playbackAudioGraphWrapper;
@Mock AudioSink outputAudioSink;
@Before
public void setUp() {
playbackAudioGraphWrapper =
new PlaybackAudioGraphWrapper(
new DefaultAudioMixer.Factory(), /* effects= */ ImmutableList.of(), outputAudioSink);
}
@After
public void tearDown() {
playbackAudioGraphWrapper.release();
}
@Test
public void processData_noAudioSinksCreated_returnsFalse() throws Exception {
assertThat(playbackAudioGraphWrapper.processData()).isFalse();
}
@Test
public void processData_audioSinkHasNotConfiguredYet_returnsFalse() throws Exception {
AudioGraphInputAudioSink unused = playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
assertThat(playbackAudioGraphWrapper.processData()).isFalse();
}
@Test
public void inputPlay_withOneInput_playsOutputSink() throws Exception {
AudioGraphInputAudioSink inputAudioSink =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
inputAudioSink.play();
verify(outputAudioSink).play();
}
@Test
public void inputPause_withOneInput_pausesOutputSink() throws Exception {
AudioGraphInputAudioSink inputAudioSink =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
inputAudioSink.play();
inputAudioSink.pause();
verify(outputAudioSink).pause();
}
@Test
public void inputReset_withOneInput_pausesOutputSink() {
AudioGraphInputAudioSink inputAudioSink =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
inputAudioSink.play();
inputAudioSink.reset();
verify(outputAudioSink).pause();
}
@Test
public void inputPlay_whenPlaying_doesNotPlayOutputSink() throws Exception {
AudioGraphInputAudioSink inputAudioSink =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
inputAudioSink.play();
inputAudioSink.play();
verify(outputAudioSink, atMostOnce()).play();
}
@Test
public void inputPause_whenNotPlaying_doesNotPauseOutputSink() throws Exception {
AudioGraphInputAudioSink inputAudioSink =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
inputAudioSink.pause();
verify(outputAudioSink, never()).pause();
}
@Test
public void someInputPlay_withMultipleInputs_doesNotPlayOutputSink() throws Exception {
AudioGraphInputAudioSink inputAudioSink1 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
AudioGraphInputAudioSink inputAudioSink2 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 1);
AudioGraphInputAudioSink unused = playbackAudioGraphWrapper.createInput(/* inputIndex= */ 2);
inputAudioSink1.play();
inputAudioSink2.play();
verify(outputAudioSink, never()).play();
}
@Test
public void allInputPlay_withMultipleInputs_playsOutputSinkOnce() throws Exception {
AudioGraphInputAudioSink inputAudioSink1 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
AudioGraphInputAudioSink inputAudioSink2 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 1);
AudioGraphInputAudioSink inputAudioSink3 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 2);
inputAudioSink1.play();
inputAudioSink2.play();
inputAudioSink3.play();
verify(outputAudioSink, atMostOnce()).play();
}
@Test
public void firstInputPause_withMultipleInputs_pausesOutputSink() throws Exception {
InOrder inOrder = inOrder(outputAudioSink);
AudioGraphInputAudioSink inputAudioSink1 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
AudioGraphInputAudioSink inputAudioSink2 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 1);
AudioGraphInputAudioSink inputAudioSink3 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 2);
inputAudioSink1.play();
inputAudioSink2.play();
inputAudioSink3.play();
inputAudioSink2.pause();
inOrder.verify(outputAudioSink).pause();
inOrder.verifyNoMoreInteractions();
}
@Test
public void allInputPause_withMultipleInputs_pausesOutputSinkOnce() throws Exception {
AudioGraphInputAudioSink inputAudioSink1 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
AudioGraphInputAudioSink inputAudioSink2 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 1);
AudioGraphInputAudioSink inputAudioSink3 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 2);
inputAudioSink1.play();
inputAudioSink2.play();
inputAudioSink3.play();
inputAudioSink2.pause();
inputAudioSink1.pause();
inputAudioSink3.pause();
verify(outputAudioSink, atMostOnce()).pause();
}
@Test
public void inputPlayAfterPause_withMultipleInputs_playsOutputSink() throws Exception {
InOrder inOrder = inOrder(outputAudioSink);
AudioGraphInputAudioSink inputAudioSink1 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 0);
AudioGraphInputAudioSink inputAudioSink2 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 1);
AudioGraphInputAudioSink inputAudioSink3 =
playbackAudioGraphWrapper.createInput(/* inputIndex= */ 2);
inputAudioSink1.play();
inputAudioSink2.play();
inputAudioSink3.play();
inputAudioSink2.pause();
inputAudioSink1.pause();
inputAudioSink2.play();
inputAudioSink1.play();
inOrder.verify(outputAudioSink).play();
inOrder.verify(outputAudioSink).pause();
inOrder.verify(outputAudioSink).play();
Mockito.verifyNoMoreInteractions(outputAudioSink);
}
}