Skip to content

Commit

Permalink
Add support for non-contiguous Ogg pages
Browse files Browse the repository at this point in the history
bear_vorbis_gap.ogg is a copy of bear_vorbis.ogg with 10 garbage bytes
(DE AD BE EF DE AD BE EF DE AD) inserted before the second capture
pattern and 3 garbage bytes inserted at the end (DE AD BE).

Issue: #7230
PiperOrigin-RevId: 314715729
  • Loading branch information
icbaker authored and ojw28 committed Jun 4, 2020
1 parent 9b5cab0 commit 3474c39
Show file tree
Hide file tree
Showing 13 changed files with 2,359 additions and 159 deletions.
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@
* HLS:
* Add support for upstream discard including cancelation of ongoing load
([#6322](https://github.com/google/ExoPlayer/issues/6322)).
* Ogg: Allow non-contiguous pages
([#7230](https://github.com/google/ExoPlayer/issues/7230)).
* Extractors:
* Add `IndexSeeker` for accurate seeks in VBR MP3 streams
([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ private long getNextSeekPosition(ExtractorInput input) throws IOException {
}

long currentPosition = input.getPosition();
if (!skipToNextPage(input, end)) {
if (!pageHeader.skipToNextPage(input, end)) {
if (start == currentPosition) {
throw new IOException("No ogg page can be found.");
}
Expand Down Expand Up @@ -200,68 +200,21 @@ private long getNextSeekPosition(ExtractorInput input) throws IOException {
* @throws IOException If reading from the input fails.
*/
private void skipToPageOfTargetGranule(ExtractorInput input) throws IOException {
pageHeader.populate(input, /* quiet= */ false);
while (pageHeader.granulePosition <= targetGranule) {
while (true) {
// If pageHeader.skipToNextPage fails to find a page it will advance input.position to the
// end of the file, so pageHeader.populate will throw EOFException (because quiet=false).
pageHeader.skipToNextPage(input);
pageHeader.populate(input, /* quiet= */ false);
if (pageHeader.granulePosition > targetGranule) {
break;
}
input.skipFully(pageHeader.headerSize + pageHeader.bodySize);
start = input.getPosition();
startGranule = pageHeader.granulePosition;
pageHeader.populate(input, /* quiet= */ false);
}
input.resetPeekPosition();
}

/**
* Skips to the next page.
*
* @param input The {@code ExtractorInput} to skip to the next page.
* @throws IOException If peeking/reading from the input fails.
* @throws EOFException If the next page can't be found before the end of the input.
*/
@VisibleForTesting
void skipToNextPage(ExtractorInput input) throws IOException {
if (!skipToNextPage(input, payloadEndPosition)) {
// Not found until eof.
throw new EOFException();
}
}

/**
* Skips to the next page. Searches for the next page header.
*
* @param input The {@code ExtractorInput} to skip to the next page.
* @param limit The limit up to which the search should take place.
* @return Whether the next page was found.
* @throws IOException If peeking/reading from the input fails.
*/
private boolean skipToNextPage(ExtractorInput input, long limit) throws IOException {
limit = Math.min(limit + 3, payloadEndPosition);
byte[] buffer = new byte[2048];
int peekLength = buffer.length;
while (true) {
if (input.getPosition() + peekLength > limit) {
// Make sure to not peek beyond the end of the input.
peekLength = (int) (limit - input.getPosition());
if (peekLength < 4) {
// Not found until end.
return false;
}
}
input.peekFully(buffer, 0, peekLength, false);
for (int i = 0; i < peekLength - 3; i++) {
if (buffer[i] == 'O'
&& buffer[i + 1] == 'g'
&& buffer[i + 2] == 'g'
&& buffer[i + 3] == 'S') {
// Match! Skip to the start of the pattern.
input.skipFully(i);
return true;
}
}
// Overlap by not skipping the entire peekLength.
input.skipFully(peekLength - 3);
}
}

/**
* Skips to the last Ogg page in the stream and reads the header's granule field which is the
* total number of samples per channel.
Expand All @@ -272,12 +225,16 @@ private boolean skipToNextPage(ExtractorInput input, long limit) throws IOExcept
*/
@VisibleForTesting
long readGranuleOfLastPage(ExtractorInput input) throws IOException {
skipToNextPage(input);
pageHeader.reset();
while ((pageHeader.type & 0x04) != 0x04 && input.getPosition() < payloadEndPosition) {
if (!pageHeader.skipToNextPage(input)) {
throw new EOFException();
}
do {
pageHeader.populate(input, /* quiet= */ false);
input.skipFully(pageHeader.headerSize + pageHeader.bodySize);
}
} while ((pageHeader.type & 0x04) != 0x04
&& pageHeader.skipToNextPage(input)
&& input.getPosition() < payloadEndPosition);
return pageHeader.granulePosition;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public boolean populate(ExtractorInput input) throws IOException {
while (!populated) {
if (currentSegmentIndex < 0) {
// We're at the start of a page.
if (!pageHeader.populate(input, true)) {
if (!pageHeader.skipToNextPage(input) || !pageHeader.populate(input, /* quiet= */ true)) {
return false;
}
int segmentIndex = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.ParsableByteArray;
import java.io.EOFException;
import java.io.IOException;
Expand All @@ -33,7 +34,8 @@
public static final int MAX_PAGE_SIZE = EMPTY_PAGE_HEADER_SIZE + MAX_SEGMENT_COUNT
+ MAX_PAGE_PAYLOAD;

private static final int TYPE_OGGS = 0x4f676753;
private static final int CAPTURE_PATTERN = 0x4f676753; // OggS
private static final int CAPTURE_PATTERN_SIZE = 4;

public int revision;
public int type;
Expand Down Expand Up @@ -73,6 +75,51 @@ public void reset() {
bodySize = 0;
}

/**
* Advances through {@code input} looking for the start of the next Ogg page.
*
* <p>Equivalent to {@link #skipToNextPage(ExtractorInput, long) skipToNextPage(input, /* limit=
* *\/ C.POSITION_UNSET)}.
*/
public boolean skipToNextPage(ExtractorInput input) throws IOException {
return skipToNextPage(input, /* limit= */ C.POSITION_UNSET);
}

/**
* Advances through {@code input} looking for the start of the next Ogg page.
*
* <p>The start of a page is identified by the 4-byte capture_pattern 'OggS'.
*
* <p>Returns {@code true} if a capture pattern was found, with the read and peek positions of
* {@code input} at the start of the page, just before the capture_pattern. Otherwise returns
* {@code false}, with the read and peek positions of {@code input} at either {@code limit} (if
* set) or end-of-input.
*
* @param input The {@link ExtractorInput} to read from (must have {@code readPosition ==
* peekPosition}).
* @param limit The max position in {@code input} to peek to, or {@link C#POSITION_UNSET} to allow
* peeking to the end.
* @return True if a capture_pattern was found.
* @throws IOException If reading data fails.
*/
public boolean skipToNextPage(ExtractorInput input, long limit) throws IOException {
Assertions.checkArgument(input.getPosition() == input.getPeekPosition());
while ((limit == C.POSITION_UNSET || input.getPosition() + CAPTURE_PATTERN_SIZE < limit)
&& peekSafely(input, scratch.data, 0, CAPTURE_PATTERN_SIZE, /* quiet= */ true)) {
scratch.reset();
if (scratch.readUnsignedInt() == CAPTURE_PATTERN) {
input.resetPeekPosition();
return true;
}
// Advance one byte before looking for the capture pattern again.
input.skipFully(1);
}
// Move the read & peek positions to limit or end-of-input, whichever is closer.
while ((limit == C.POSITION_UNSET || input.getPosition() < limit)
&& input.skip(1) != C.RESULT_END_OF_INPUT) {}
return false;
}

/**
* Peeks an Ogg page header and updates this {@link OggPageHeader}.
*
Expand All @@ -84,23 +131,11 @@ public void reset() {
* @throws IOException If reading data fails or the stream is invalid.
*/
public boolean populate(ExtractorInput input, boolean quiet) throws IOException {
scratch.reset();
reset();
boolean hasEnoughBytes = input.getLength() == C.LENGTH_UNSET
|| input.getLength() - input.getPeekPosition() >= EMPTY_PAGE_HEADER_SIZE;
if (!hasEnoughBytes || !input.peekFully(scratch.data, 0, EMPTY_PAGE_HEADER_SIZE, true)) {
if (quiet) {
return false;
} else {
throw new EOFException();
}
}
if (scratch.readUnsignedInt() != TYPE_OGGS) {
if (quiet) {
return false;
} else {
throw new ParserException("expected OggS capture pattern at begin of page");
}
scratch.reset();
if (!peekSafely(input, scratch.data, 0, EMPTY_PAGE_HEADER_SIZE, quiet)
|| scratch.readUnsignedInt() != CAPTURE_PATTERN) {
return false;
}

revision = scratch.readUnsignedByte();
Expand Down Expand Up @@ -130,4 +165,31 @@ public boolean populate(ExtractorInput input, boolean quiet) throws IOException

return true;
}

/**
* Peek data from {@code input}, respecting {@code quiet}. Return true if the peek is successful.
*
* <p>If {@code quiet=false} then encountering the end of the input (whether before or after
* reading some data) will throw {@link EOFException}.
*
* <p>If {@code quiet=true} then encountering the end of the input (even after reading some data)
* will return {@code false}.
*
* <p>This is slightly different to the behaviour of {@link ExtractorInput#peekFully(byte[], int,
* int, boolean)}, where {@code allowEndOfInput=true} only returns false (and suppresses the
* exception) if the end of the input is reached before reading any data.
*/
private static boolean peekSafely(
ExtractorInput input, byte[] output, int offset, int length, boolean quiet)
throws IOException {
try {
return input.peekFully(output, offset, length, /* allowEndOfInput= */ quiet);
} catch (EOFException e) {
if (quiet) {
return false;
} else {
throw e;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.testutil.FakeExtractorInput;
import com.google.android.exoplayer2.testutil.TestUtil;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.common.primitives.Bytes;
import java.io.EOFException;
import java.io.IOException;
import java.util.Random;
Expand Down Expand Up @@ -122,53 +120,6 @@ public void seeking() throws Exception {
}
}

@Test
public void skipToNextPage_success() throws Exception {
FakeExtractorInput extractorInput =
createInput(
Bytes.concat(
TestUtil.buildTestData(4000, random),
new byte[] {'O', 'g', 'g', 'S'},
TestUtil.buildTestData(4000, random)),
/* simulateUnknownLength= */ false);
skipToNextPage(extractorInput);
assertThat(extractorInput.getPosition()).isEqualTo(4000);
}

@Test
public void skipToNextPage_withOverlappingInput_success() throws Exception {
FakeExtractorInput extractorInput =
createInput(
Bytes.concat(
TestUtil.buildTestData(2046, random),
new byte[] {'O', 'g', 'g', 'S'},
TestUtil.buildTestData(4000, random)),
/* simulateUnknownLength= */ false);
skipToNextPage(extractorInput);
assertThat(extractorInput.getPosition()).isEqualTo(2046);
}

@Test
public void skipToNextPage_withInputShorterThanPeekLength_success() throws Exception {
FakeExtractorInput extractorInput =
createInput(
Bytes.concat(new byte[] {'x', 'O', 'g', 'g', 'S'}), /* simulateUnknownLength= */ false);
skipToNextPage(extractorInput);
assertThat(extractorInput.getPosition()).isEqualTo(1);
}

@Test
public void skipToNextPage_withoutMatch_throwsException() throws Exception {
FakeExtractorInput extractorInput =
createInput(new byte[] {'g', 'g', 'S', 'O', 'g', 'g'}, /* simulateUnknownLength= */ false);
try {
skipToNextPage(extractorInput);
fail();
} catch (EOFException e) {
// expected
}
}

@Test
public void readGranuleOfLastPage() throws IOException {
// This test stream has three headers with granule numbers 20000, 40000 and 60000.
Expand Down Expand Up @@ -200,25 +151,6 @@ public void readGranuleOfLastPage_withUnboundedLength_throwsException() throws E
}
}

private static void skipToNextPage(ExtractorInput extractorInput) throws IOException {
DefaultOggSeeker oggSeeker =
new DefaultOggSeeker(
/* streamReader= */ new FlacReader(),
/* payloadStartPosition= */ 0,
/* payloadEndPosition= */ extractorInput.getLength(),
/* firstPayloadPageSize= */ 1,
/* firstPayloadPageGranulePosition= */ 2,
/* firstPayloadPageIsLastPage= */ false);
while (true) {
try {
oggSeeker.skipToNextPage(extractorInput);
break;
} catch (FakeExtractorInput.SimulatedIOException e) {
/* ignored */
}
}
}

private static void assertReadGranuleOfLastPage(FakeExtractorInput input, int expected)
throws IOException {
DefaultOggSeeker oggSeeker =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,11 @@ public void flacNoSeektable() throws Exception {
public void vorbis() throws Exception {
ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_vorbis.ogg", simulationConfig);
}

// Ensure the extractor can handle non-contiguous pages by using a file with 10 bytes of garbage
// data before the start of the second page.
@Test
public void vorbisWithGapBeforeSecondPage() throws Exception {
ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_vorbis_gap.ogg", simulationConfig);
}
}
Loading

0 comments on commit 3474c39

Please sign in to comment.