diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ad9b18d73f..5cfd267ada 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,6 +4,11 @@ * MediaSession extension: * Allow apps to set custom errors. +* Audio: + * Support seeking for the AMR container format using constant bitrate seek + map. + * Add support for mu-law and A-law PCM with the ffmpeg extension + ([#4360](https://github.com/google/ExoPlayer/issues/4360)). * Allow apps to pass a `CacheKeyFactory` for setting custom cache keys when creating a `CacheDataSource`. * Turned on Java 8 compiler support for the ExoPlayer library. Apps that depend @@ -31,8 +36,6 @@ two additional convenience methods `Player.getTotalBufferedDuration` and `Player.getContentBufferedDuration` ([#4023](https://github.com/google/ExoPlayer/issues/4023)). -* Add support for mu-law and A-law PCM with the ffmpeg extension - ([#4360](https://github.com/google/ExoPlayer/issues/4360)). * MediaSession extension: * Allow apps to set custom metadata with a MediaMetadataProvider ([#3497](https://github.com/google/ExoPlayer/issues/3497)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/C.java b/library/core/src/main/java/com/google/android/exoplayer2/C.java index c16d6777b2..d7123c8078 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/C.java @@ -77,6 +77,9 @@ public final class C { */ public static final long NANOS_PER_SECOND = 1000000000L; + /** The number of bits per byte. */ + public static final int BITS_PER_BYTE = 8; + /** * The name of the ASCII charset. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java new file mode 100644 index 0000000000..abce01b5ef --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2018 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.extractor; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Util; + +/** + * A {@link SeekMap} implementation that assumes the stream has a constant bitrate and consists of + * multiple independent frames of the same size. Seek points are calculated to be at frame + * boundaries. + */ +public class ConstantBitrateSeekMap implements SeekMap { + + private final long inputLength; + private final long firstFrameBytePosition; + private final int frameSize; + private final long dataSize; + private final int bitrate; + private final long durationUs; + + /** + * Constructs a new instance from a stream. + * + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param firstFrameBytePosition The byte-position of the first frame in the stream. + * @param bitrate The bitrate (which is assumed to be constant in the stream). + * @param frameSize The size of each frame in the stream in bytes. May be {@link C#LENGTH_UNSET} + * if unknown. + */ + public ConstantBitrateSeekMap( + long inputLength, long firstFrameBytePosition, int bitrate, int frameSize) { + this.inputLength = inputLength; + this.firstFrameBytePosition = firstFrameBytePosition; + this.frameSize = frameSize == C.LENGTH_UNSET ? 1 : frameSize; + this.bitrate = bitrate; + + if (inputLength == C.LENGTH_UNSET) { + dataSize = C.LENGTH_UNSET; + durationUs = C.TIME_UNSET; + } else { + dataSize = inputLength - firstFrameBytePosition; + durationUs = getTimeUsAtPosition(inputLength, firstFrameBytePosition, bitrate); + } + } + + @Override + public boolean isSeekable() { + return dataSize != C.LENGTH_UNSET; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + if (dataSize == C.LENGTH_UNSET) { + return new SeekPoints(new SeekPoint(0, firstFrameBytePosition)); + } + long seekFramePosition = getFramePositionForTimeUs(timeUs); + long seekTimeUs = getTimeUsAtPosition(seekFramePosition); + SeekPoint seekPoint = new SeekPoint(seekTimeUs, seekFramePosition); + if (seekTimeUs >= timeUs || seekFramePosition + frameSize >= inputLength) { + return new SeekPoints(seekPoint); + } else { + long secondSeekPosition = seekFramePosition + frameSize; + long secondSeekTimeUs = getTimeUsAtPosition(secondSeekPosition); + SeekPoint secondSeekPoint = new SeekPoint(secondSeekTimeUs, secondSeekPosition); + return new SeekPoints(seekPoint, secondSeekPoint); + } + } + + @Override + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the stream time in microseconds for a given position. + * + * @param position The stream byte-position. + * @return The stream time in microseconds for the given position. + */ + public long getTimeUsAtPosition(long position) { + return getTimeUsAtPosition(position, firstFrameBytePosition, bitrate); + } + + // Internal methods + + /** + * Returns the stream time in microseconds for a given stream position. + * + * @param position The stream byte-position. + * @param firstFrameBytePosition The position of the first frame in the stream. + * @param bitrate The bitrate (which is assumed to be constant in the stream). + * @return The stream time in microseconds for the given stream position. + */ + private static long getTimeUsAtPosition(long position, long firstFrameBytePosition, int bitrate) { + return Math.max(0, position - firstFrameBytePosition) + * C.BITS_PER_BYTE + * C.MICROS_PER_SECOND + / bitrate; + } + + private long getFramePositionForTimeUs(long timeUs) { + long positionOffset = (timeUs * bitrate) / (C.MICROS_PER_SECOND * C.BITS_PER_BYTE); + // Constrain to nearest preceding frame offset. + positionOffset = (positionOffset / frameSize) * frameSize; + positionOffset = + Util.constrainValue(positionOffset, /* min= */ 0, /* max= */ dataSize - frameSize); + return firstFrameBytePosition + positionOffset; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/amr/AmrExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/amr/AmrExtractor.java index b58e979c26..194a711b68 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/amr/AmrExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/amr/AmrExtractor.java @@ -15,9 +15,12 @@ */ package com.google.android.exoplayer2.extractor.amr; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.extractor.ConstantBitrateSeekMap; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -29,6 +32,8 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Arrays; /** @@ -49,6 +54,18 @@ public final class AmrExtractor implements Extractor { } }; + /** Flags controlling the behavior of the extractor. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_ENABLE_CONSTANT_BITRATE_SEEKING}) + public @interface Flags {} + /** + * Flag to force enable seeking using a constant bitrate assumption in cases where seeking would + * otherwise not be possible. + */ + public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING = 1; + /** * The frame size in bytes, including header (1 byte), for each of the 16 frame types for AMR * narrow band. @@ -100,23 +117,43 @@ public final class AmrExtractor implements Extractor { /** Theoretical maximum frame size for a AMR frame. */ private static final int MAX_FRAME_SIZE_BYTES = frameSizeBytesByTypeWb[8]; + /** + * The required number of samples in the stream with same sample size to classify the stream as a + * constant-bitrate-stream. + */ + private static final int NUM_SAME_SIZE_CONSTANT_BIT_RATE_THRESHOLD = 20; private static final int SAMPLE_RATE_WB = 16_000; private static final int SAMPLE_RATE_NB = 8_000; private static final int SAMPLE_TIME_PER_FRAME_US = 20_000; private final byte[] scratch; + private final @Flags int flags; private boolean isWideBand; private long currentSampleTimeUs; - private int currentSampleTotalBytes; + private int currentSampleSize; private int currentSampleBytesRemaining; + private boolean hasOutputSeekMap; + private long firstSamplePosition; + private int firstSampleSize; + private int numSamplesWithSameSize; + private long timeOffsetUs; + private ExtractorOutput extractorOutput; private TrackOutput trackOutput; + private @Nullable SeekMap seekMap; private boolean hasOutputFormat; public AmrExtractor() { + this(/* flags= */ 0); + } + + /** @param flags Flags that control the extractor's behavior. */ + public AmrExtractor(@Flags int flags) { + this.flags = flags; scratch = new byte[1]; + firstSampleSize = C.LENGTH_UNSET; } // Extractor implementation. @@ -127,10 +164,10 @@ public final class AmrExtractor implements Extractor { } @Override - public void init(ExtractorOutput output) { - output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); - trackOutput = output.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); - output.endTracks(); + public void init(ExtractorOutput extractorOutput) { + this.extractorOutput = extractorOutput; + trackOutput = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); + extractorOutput.endTracks(); } @Override @@ -142,14 +179,21 @@ public final class AmrExtractor implements Extractor { } } maybeOutputFormat(); - return readSample(input); + int sampleReadResult = readSample(input); + maybeOutputSeekMap(input.getLength(), sampleReadResult); + return sampleReadResult; } @Override public void seek(long position, long timeUs) { currentSampleTimeUs = 0; - currentSampleTotalBytes = 0; + currentSampleSize = 0; currentSampleBytesRemaining = 0; + if (position != 0 && seekMap instanceof ConstantBitrateSeekMap) { + timeOffsetUs = ((ConstantBitrateSeekMap) seekMap).getTimeUsAtPosition(position); + } else { + timeOffsetUs = 0; + } } @Override @@ -228,11 +272,18 @@ public final class AmrExtractor implements Extractor { private int readSample(ExtractorInput extractorInput) throws IOException, InterruptedException { if (currentSampleBytesRemaining == 0) { try { - currentSampleTotalBytes = readNextSampleSize(extractorInput); + currentSampleSize = peekNextSampleSize(extractorInput); } catch (EOFException e) { return RESULT_END_OF_INPUT; } - currentSampleBytesRemaining = currentSampleTotalBytes; + currentSampleBytesRemaining = currentSampleSize; + if (firstSampleSize == C.LENGTH_UNSET) { + firstSamplePosition = extractorInput.getPosition(); + firstSampleSize = currentSampleSize; + } + if (firstSampleSize == currentSampleSize) { + numSamplesWithSameSize++; + } } int bytesAppended = @@ -247,16 +298,16 @@ public final class AmrExtractor implements Extractor { } trackOutput.sampleMetadata( - currentSampleTimeUs, + timeOffsetUs + currentSampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, - currentSampleTotalBytes, + currentSampleSize, /* offset= */ 0, /* encryptionData= */ null); currentSampleTimeUs += SAMPLE_TIME_PER_FRAME_US; return RESULT_CONTINUE; } - private int readNextSampleSize(ExtractorInput extractorInput) + private int peekNextSampleSize(ExtractorInput extractorInput) throws IOException, InterruptedException { extractorInput.resetPeekPosition(); extractorInput.peekFully(scratch, /* offset= */ 0, /* length= */ 1); @@ -296,4 +347,39 @@ public final class AmrExtractor implements Extractor { // For narrow band, type 12-14 are for future use. return !isWideBand && (frameType < 12 || frameType > 14); } + + private void maybeOutputSeekMap(long inputLength, int sampleReadResult) { + if (hasOutputSeekMap) { + return; + } + + if ((flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) == 0 + || inputLength == C.LENGTH_UNSET + || (firstSampleSize != C.LENGTH_UNSET && firstSampleSize != currentSampleSize)) { + seekMap = new SeekMap.Unseekable(C.TIME_UNSET); + extractorOutput.seekMap(seekMap); + hasOutputSeekMap = true; + } else if (numSamplesWithSameSize >= NUM_SAME_SIZE_CONSTANT_BIT_RATE_THRESHOLD + || sampleReadResult == RESULT_END_OF_INPUT) { + seekMap = getConstantBitrateSeekMap(inputLength); + extractorOutput.seekMap(seekMap); + hasOutputSeekMap = true; + } + } + + private SeekMap getConstantBitrateSeekMap(long inputLength) { + int bitrate = getBitrateFromFrameSize(firstSampleSize, SAMPLE_TIME_PER_FRAME_US); + return new ConstantBitrateSeekMap(inputLength, firstSamplePosition, bitrate, firstSampleSize); + } + + /** + * Returns the stream bitrate, given a frame size and the duration of that frame in microseconds. + * + * @param frameSize The size of each frame in the stream. + * @param durationUsPerFrame The duration of the given frame in microseconds. + * @return The stream bitrate. + */ + private static int getBitrateFromFrameSize(int frameSize, long durationUsPerFrame) { + return (int) ((frameSize * C.BITS_PER_BYTE * C.MICROS_PER_SECOND) / durationUsPerFrame); + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java index d358c0cae1..bffc43a540 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java @@ -16,78 +16,27 @@ package com.google.android.exoplayer2.extractor.mp3; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.ConstantBitrateSeekMap; import com.google.android.exoplayer2.extractor.MpegAudioHeader; -import com.google.android.exoplayer2.extractor.SeekPoint; -import com.google.android.exoplayer2.util.Util; /** * MP3 seeker that doesn't rely on metadata and seeks assuming the source has a constant bitrate. */ -/* package */ final class ConstantBitrateSeeker implements Mp3Extractor.Seeker { - - private static final int BITS_PER_BYTE = 8; - - private final long firstFramePosition; - private final int frameSize; - private final long dataSize; - private final int bitrate; - private final long durationUs; +/* package */ final class ConstantBitrateSeeker extends ConstantBitrateSeekMap + implements Mp3Extractor.Seeker { /** * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. * @param firstFramePosition The position of the first frame in the stream. * @param mpegAudioHeader The MPEG audio header associated with the first frame. */ - public ConstantBitrateSeeker(long inputLength, long firstFramePosition, - MpegAudioHeader mpegAudioHeader) { - this.firstFramePosition = firstFramePosition; - this.frameSize = mpegAudioHeader.frameSize; - this.bitrate = mpegAudioHeader.bitrate; - if (inputLength == C.LENGTH_UNSET) { - dataSize = C.LENGTH_UNSET; - durationUs = C.TIME_UNSET; - } else { - dataSize = inputLength - firstFramePosition; - durationUs = getTimeUs(inputLength); - } - } - - @Override - public boolean isSeekable() { - return dataSize != C.LENGTH_UNSET; - } - - @Override - public SeekPoints getSeekPoints(long timeUs) { - if (dataSize == C.LENGTH_UNSET) { - return new SeekPoints(new SeekPoint(0, firstFramePosition)); - } - long positionOffset = (timeUs * bitrate) / (C.MICROS_PER_SECOND * BITS_PER_BYTE); - // Constrain to nearest preceding frame offset. - positionOffset = (positionOffset / frameSize) * frameSize; - positionOffset = Util.constrainValue(positionOffset, 0, dataSize - frameSize); - long seekPosition = firstFramePosition + positionOffset; - long seekTimeUs = getTimeUs(seekPosition); - SeekPoint seekPoint = new SeekPoint(seekTimeUs, seekPosition); - if (seekTimeUs >= timeUs || positionOffset == dataSize - frameSize) { - return new SeekPoints(seekPoint); - } else { - long secondSeekPosition = seekPosition + frameSize; - long secondSeekTimeUs = getTimeUs(secondSeekPosition); - SeekPoint secondSeekPoint = new SeekPoint(secondSeekTimeUs, secondSeekPosition); - return new SeekPoints(seekPoint, secondSeekPoint); - } + public ConstantBitrateSeeker( + long inputLength, long firstFramePosition, MpegAudioHeader mpegAudioHeader) { + super(inputLength, firstFramePosition, mpegAudioHeader.bitrate, mpegAudioHeader.frameSize); } @Override public long getTimeUs(long position) { - return (Math.max(0, position - firstFramePosition) * C.MICROS_PER_SECOND * BITS_PER_BYTE) - / bitrate; + return getTimeUsAtPosition(position); } - - @Override - public long getDurationUs() { - return durationUs; - } - } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMapTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMapTest.java new file mode 100644 index 0000000000..0fa33dd348 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMapTest.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2018 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.extractor; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.android.exoplayer2.C; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test for {@link ConstantBitrateSeekMap}. */ +@RunWith(RobolectricTestRunner.class) +public final class ConstantBitrateSeekMapTest { + + private ConstantBitrateSeekMap constantBitrateSeekMap; + + @Test + public void testIsSeekable_forKnownInputLength_returnSeekable() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 1000, + /* firstFrameBytePosition= */ 0, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + assertThat(constantBitrateSeekMap.isSeekable()).isTrue(); + } + + @Test + public void testIsSeekable_forUnknownInputLength_returnUnseekable() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ C.LENGTH_UNSET, + /* firstFrameBytePosition= */ 0, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + assertThat(constantBitrateSeekMap.isSeekable()).isFalse(); + } + + @Test + public void testGetSeekPoints_forUnseekableInput_returnSeekPoint0() { + int firstBytePosition = 100; + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ C.LENGTH_UNSET, + /* firstFrameBytePosition= */ firstBytePosition, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + SeekMap.SeekPoints seekPoints = constantBitrateSeekMap.getSeekPoints(/* timeUs= */ 123); + assertThat(seekPoints.first.timeUs).isEqualTo(0); + assertThat(seekPoints.first.position).isEqualTo(firstBytePosition); + assertThat(seekPoints.second).isEqualTo(seekPoints.first); + } + + @Test + public void testGetDurationUs_forKnownInputLength_returnCorrectDuration() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + // Bitrate = 8000 (bits/s) = 1000 (bytes/s) + // FrameSize = 100 (bytes), so 1 frame = 1s = 100_000 us + // Input length = 2300 (bytes), first frame = 100, so duration = 2_200_000 us. + assertThat(constantBitrateSeekMap.getDurationUs()).isEqualTo(2_200_000); + } + + @Test + public void testGetDurationUs_forUnnnownInputLength_returnUnknownDuration() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ C.LENGTH_UNSET, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + assertThat(constantBitrateSeekMap.getDurationUs()).isEqualTo(C.TIME_UNSET); + } + + @Test + public void testGetSeekPoints_forSeekableInput_forSyncPosition0_return1SeekPoint() { + int firstBytePosition = 100; + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ firstBytePosition, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + SeekMap.SeekPoints seekPoints = constantBitrateSeekMap.getSeekPoints(/* timeUs= */ 0); + assertThat(seekPoints.first.timeUs).isEqualTo(0); + assertThat(seekPoints.first.position).isEqualTo(firstBytePosition); + assertThat(seekPoints.second).isEqualTo(seekPoints.first); + } + + @Test + public void testGetSeekPoints_forSeekableInput_forSeekPointAtSyncPosition_return1SeekPoint() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + SeekMap.SeekPoints seekPoints = constantBitrateSeekMap.getSeekPoints(/* timeUs= */ 1_200_000); + // Bitrate = 8000 (bits/s) = 1000 (bytes/s) + // FrameSize = 100 (bytes), so 1 frame = 1s = 100_000 us + assertThat(seekPoints.first.timeUs).isEqualTo(1_200_000); + assertThat(seekPoints.first.position).isEqualTo(1300); + assertThat(seekPoints.second).isEqualTo(seekPoints.first); + } + + @Test + public void testGetSeekPoints_forSeekableInput_forNonSyncSeekPosition_return2SeekPoints() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + SeekMap.SeekPoints seekPoints = constantBitrateSeekMap.getSeekPoints(/* timeUs= */ 345_678); + // Bitrate = 8000 (bits/s) = 1000 (bytes/s) + // FrameSize = 100 (bytes), so 1 frame = 1s = 100_000 us + assertThat(seekPoints.first.timeUs).isEqualTo(300_000); + assertThat(seekPoints.first.position).isEqualTo(400); + assertThat(seekPoints.second.timeUs).isEqualTo(400_000); + assertThat(seekPoints.second.position).isEqualTo(500); + } + + @Test + public void testGetSeekPoints_forSeekableInput_forSeekPointWithinLastFrame_return1SeekPoint() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + SeekMap.SeekPoints seekPoints = constantBitrateSeekMap.getSeekPoints(/* timeUs= */ 2_123_456); + assertThat(seekPoints.first.timeUs).isEqualTo(2_100_000); + assertThat(seekPoints.first.position).isEqualTo(2_200); + assertThat(seekPoints.second).isEqualTo(seekPoints.first); + } + + @Test + public void testGetSeekPoints_forSeekableInput_forSeekPointAtEndOfStream_return1SeekPoint() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + SeekMap.SeekPoints seekPoints = constantBitrateSeekMap.getSeekPoints(/* timeUs= */ 2_200_000); + assertThat(seekPoints.first.timeUs).isEqualTo(2_100_000); + assertThat(seekPoints.first.position).isEqualTo(2_200); + assertThat(seekPoints.second).isEqualTo(seekPoints.first); + } + + @Test + public void testGetTimeUsAtPosition_forPosition0_return0() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + long timeUs = constantBitrateSeekMap.getTimeUsAtPosition(0); + assertThat(timeUs).isEqualTo(0); + } + + @Test + public void testGetTimeUsAtPosition_forPositionWithinStream_returnCorrectTime() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + long timeUs = constantBitrateSeekMap.getTimeUsAtPosition(1234); + assertThat(timeUs).isEqualTo(1_134_000); + } + + @Test + public void testGetTimeUsAtPosition_forPositionAtEndOfStream_returnStreamDuration() { + constantBitrateSeekMap = + new ConstantBitrateSeekMap( + /* inputLength= */ 2_300, + /* firstFrameBytePosition= */ 100, + /* bitrate= */ 8_000, + /* frameSize= */ 100); + long timeUs = constantBitrateSeekMap.getTimeUsAtPosition(2300); + assertThat(timeUs).isEqualTo(constantBitrateSeekMap.getDurationUs()); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java new file mode 100644 index 0000000000..b7098abfcf --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java @@ -0,0 +1,472 @@ +/* + * Copyright (C) 2018 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.extractor.amr; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.FakeExtractorOutput; +import com.google.android.exoplayer2.testutil.FakeTrackOutput; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.List; +import java.util.Random; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Unit test for {@link AmrExtractor} narrow-band AMR file. */ +@RunWith(RobolectricTestRunner.class) +public final class AmrExtractorSeekTest { + + private static final Random random = new Random(1234L); + + private static final String NARROW_BAND_AMR_FILE = "amr/sample_nb.amr"; + private static final int NARROW_BAND_FILE_DURATION_US = 4_360_000; + + private static final String WIDE_BAND_AMR_FILE = "amr/sample_wb.amr"; + private static final int WIDE_BAND_FILE_DURATION_US = 3_380_000; + + private FakeTrackOutput expectedTrackOutput; + private DefaultDataSource dataSource; + private PositionHolder positionHolder; + + private long totalInputLength; + + @Before + public void setUp() { + dataSource = + new DefaultDataSourceFactory(RuntimeEnvironment.application, "UserAgent") + .createDataSource(); + positionHolder = new PositionHolder(); + } + + @Test + public void testAmrExtractorReads_returnSeekableSeekMap_forNarrowBandAmr() + throws IOException, InterruptedException { + String fileName = NARROW_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + SeekMap seekMap = extractSeekMap(extractor, new FakeExtractorOutput(), fileName); + + assertThat(seekMap).isNotNull(); + assertThat(seekMap.getDurationUs()).isEqualTo(NARROW_BAND_FILE_DURATION_US); + assertThat(seekMap.isSeekable()).isTrue(); + } + + @Test + public void testSeeking_handlesSeekingToPositionInFile_extractsCorrectFrame_forNarrowBandAmr() + throws IOException, InterruptedException { + String fileName = NARROW_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long targetSeekTimeUs = 980_000; + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesSeekToEoF_extractsLastFrame_forNarrowBandAmr() + throws IOException, InterruptedException { + String fileName = NARROW_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long targetSeekTimeUs = seekMap.getDurationUs(); + + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesSeekingBackward_extractsCorrectFrames_forNarrowBandAmr() + throws IOException, InterruptedException { + String fileName = NARROW_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long firstSeekTimeUs = 980_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput, fileName); + + long targetSeekTimeUs = 0; + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesSeekingForward_extractsCorrectFrames_forNarrowBandAmr() + throws IOException, InterruptedException { + String fileName = NARROW_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long firstSeekTimeUs = 980_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput, fileName); + + long targetSeekTimeUs = 1_200_000; + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesRandomSeeks_extractsCorrectFrames_forNarrowBandAmr() + throws IOException, InterruptedException { + String fileName = NARROW_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long numSeek = 100; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = random.nextInt(NARROW_BAND_FILE_DURATION_US + 1); + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + } + + @Test + public void testAmrExtractorReads_returnSeekableSeekMap_forWideBandAmr() + throws IOException, InterruptedException { + String fileName = WIDE_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + SeekMap seekMap = extractSeekMap(extractor, new FakeExtractorOutput(), fileName); + + assertThat(seekMap).isNotNull(); + assertThat(seekMap.getDurationUs()).isEqualTo(WIDE_BAND_FILE_DURATION_US); + assertThat(seekMap.isSeekable()).isTrue(); + } + + @Test + public void testSeeking_handlesSeekingToPositionInFile_extractsCorrectFrame_forWideBandAmr() + throws IOException, InterruptedException { + String fileName = WIDE_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long targetSeekTimeUs = 980_000; + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesSeekToEoF_extractsLastFrame_forWideBandAmr() + throws IOException, InterruptedException { + String fileName = WIDE_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long targetSeekTimeUs = seekMap.getDurationUs(); + + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesSeekingBackward_extractsCorrectFrames_forWideBandAmr() + throws IOException, InterruptedException { + String fileName = WIDE_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long firstSeekTimeUs = 980_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput, fileName); + + long targetSeekTimeUs = 0; + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesSeekingForward_extractsCorrectFrames_forWideBandAmr() + throws IOException, InterruptedException { + String fileName = WIDE_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long firstSeekTimeUs = 980_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput, fileName); + + long targetSeekTimeUs = 1_200_000; + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testSeeking_handlesRandomSeeks_extractsCorrectFrames_forWideBandAmr() + throws IOException, InterruptedException { + String fileName = WIDE_BAND_AMR_FILE; + expectedTrackOutput = + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, fileName); + totalInputLength = readInputLength(fileName); + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput, fileName); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long numSeek = 100; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = random.nextInt(NARROW_BAND_FILE_DURATION_US + 1); + int extractedFrameIndex = + seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput, fileName); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + } + + // Internal methods + + private static String assetPathForFile(String fileName) { + return "asset:///" + fileName; + } + + private long readInputLength(String fileName) throws IOException { + DataSpec dataSpec = + new DataSpec( + Uri.parse(assetPathForFile(fileName)), + /* absoluteStreamPosition= */ 0, + /* length= */ C.LENGTH_UNSET, + /* key= */ null); + long totalInputLength = dataSource.open(dataSpec); + Util.closeQuietly(dataSource); + return totalInputLength; + } + + /** + * Seeks to the given seek time and keeps reading from input until we can extract at least one + * frame from the seek position, or until end-of-input is reached. + * + * @return The index of the first extracted frame written to the given {@code trackOutput} after + * the seek is completed, or -1 if the seek is completed without any extracted frame. + */ + private int seekToTimeUs( + AmrExtractor amrExtractor, + SeekMap seekMap, + long seekTimeUs, + FakeTrackOutput trackOutput, + String fileName) + throws IOException, InterruptedException { + int numSampleBeforeSeek = trackOutput.getSampleCount(); + SeekMap.SeekPoints seekPoints = seekMap.getSeekPoints(seekTimeUs); + + long initialSeekLoadPosition = seekPoints.first.position; + amrExtractor.seek(initialSeekLoadPosition, seekTimeUs); + + positionHolder.position = C.POSITION_UNSET; + ExtractorInput extractorInput = + getExtractorInputFromPosition(initialSeekLoadPosition, fileName); + int extractorReadResult = Extractor.RESULT_CONTINUE; + while (true) { + try { + // Keep reading until we can read at least one frame after seek + while (extractorReadResult == Extractor.RESULT_CONTINUE + && trackOutput.getSampleCount() == numSampleBeforeSeek) { + extractorReadResult = amrExtractor.read(extractorInput, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + + if (extractorReadResult == Extractor.RESULT_SEEK) { + extractorInput = getExtractorInputFromPosition(positionHolder.position, fileName); + extractorReadResult = Extractor.RESULT_CONTINUE; + } else if (extractorReadResult == Extractor.RESULT_END_OF_INPUT) { + return -1; + } else if (trackOutput.getSampleCount() > numSampleBeforeSeek) { + // First index after seek = num sample before seek. + return numSampleBeforeSeek; + } + } + } + + private @Nullable SeekMap extractSeekMap( + AmrExtractor extractor, FakeExtractorOutput output, String fileName) + throws IOException, InterruptedException { + try { + ExtractorInput input = getExtractorInputFromPosition(/* position= */ 0, fileName); + extractor.init(output); + while (output.seekMap == null) { + extractor.read(input, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + return output.seekMap; + } + + private void assertFirstFrameAfterSeekContainTargetSeekTime( + FakeTrackOutput trackOutput, long seekTimeUs, int firstFrameIndexAfterSeek) { + int expectedSampleIndex = findTargetFrameInExpectedOutput(seekTimeUs); + // Assert that after seeking, the first sample frame written to output contains the sample + // at seek time. + trackOutput.assertSample( + firstFrameIndexAfterSeek, + expectedTrackOutput.getSampleData(expectedSampleIndex), + expectedTrackOutput.getSampleTimeUs(expectedSampleIndex), + expectedTrackOutput.getSampleFlags(expectedSampleIndex), + expectedTrackOutput.getSampleCryptoData(expectedSampleIndex)); + } + + private int findTargetFrameInExpectedOutput(long seekTimeUs) { + List sampleTimes = expectedTrackOutput.getSampleTimesUs(); + for (int i = 0; i < sampleTimes.size() - 1; i++) { + long currentSampleTime = sampleTimes.get(i); + long nextSampleTime = sampleTimes.get(i + 1); + if (currentSampleTime <= seekTimeUs && nextSampleTime > seekTimeUs) { + return i; + } + } + return sampleTimes.size() - 1; + } + + private ExtractorInput getExtractorInputFromPosition(long position, String fileName) + throws IOException { + DataSpec dataSpec = + new DataSpec( + Uri.parse(assetPathForFile(fileName)), position, totalInputLength, /* key= */ null); + dataSource.open(dataSpec); + return new DefaultExtractorInput(dataSource, position, totalInputLength); + } + + private FakeTrackOutput extractAllSamplesFromFileToExpectedOutput( + Context context, String fileName) throws IOException, InterruptedException { + byte[] data = TestUtil.getByteArray(context, fileName); + + AmrExtractor extractor = new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + FakeExtractorOutput expectedOutput = new FakeExtractorOutput(); + extractor.init(expectedOutput); + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + + while (extractor.read(input, new PositionHolder()) != Extractor.RESULT_END_OF_INPUT) {} + return expectedOutput.trackOutputs.get(0); + } +}