From a067bd0965ea78f63ff41bbe9c50987ba92be3a5 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 15 Mar 2016 10:29:36 -0700 Subject: [PATCH] Make OggVorbisExtractor seekable. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=117252304 --- .../extractor/ogg/OggReaderTest.java | 167 ++++++++++++-- .../extractor/ogg/OggSeekerTest.java | 132 +++++++++++ .../exoplayer/extractor/ogg/OggUtilTest.java | 190 ++++++++++++++++ .../extractor/ogg/OggVorbisExtractorTest.java | 25 ++- .../exoplayer/extractor/ogg/TestData.java | 8 +- .../extractor/ogg/VorbisUtilTest.java | 48 +++- .../exoplayer/extractor/ogg/OggReader.java | 194 ++++++++-------- .../exoplayer/extractor/ogg/OggSeeker.java | 87 ++++++++ .../exoplayer/extractor/ogg/OggUtil.java | 209 ++++++++++++++++++ .../extractor/ogg/OggVorbisExtractor.java | 165 ++++++++++---- .../exoplayer/extractor/ogg/VorbisUtil.java | 67 +++--- 11 files changed, 1083 insertions(+), 209 deletions(-) create mode 100644 library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggSeekerTest.java create mode 100644 library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggUtilTest.java create mode 100644 library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggSeeker.java create mode 100644 library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggReaderTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggReaderTest.java index ddc645628c..f1270aeb2f 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggReaderTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggReaderTest.java @@ -15,8 +15,9 @@ */ package com.google.android.exoplayer.extractor.ogg; +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.extractor.ExtractorInput; import com.google.android.exoplayer.testutil.FakeExtractorInput; -import com.google.android.exoplayer.testutil.FakeExtractorInput.SimulatedIOException; import com.google.android.exoplayer.testutil.TestUtil; import com.google.android.exoplayer.util.ParsableByteArray; @@ -24,12 +25,13 @@ import android.test.MoreAsserts; import junit.framework.TestCase; +import java.io.EOFException; import java.io.IOException; import java.util.Arrays; import java.util.Random; /** - * Unit test for {@link OggReader} + * Unit test for {@link OggReader}. */ public final class OggReaderTest extends TestCase { @@ -51,7 +53,7 @@ public final class OggReaderTest extends TestCase { byte[] thirdPacket = TestUtil.buildTestData(256, random); byte[] fourthPacket = TestUtil.buildTestData(271, random); - FakeExtractorInput input = createInput( + FakeExtractorInput input = TestData.createInput( TestUtil.joinByteArrays( // First page with a single packet. TestData.buildOggHeader(0x02, 0, 1000, 0x01), @@ -67,7 +69,7 @@ public final class OggReaderTest extends TestCase { TestData.buildOggHeader(0x04, 128, 1003, 0x04), TestUtil.createByteArray(0xFF, 0x01, 0xFF, 0x10), // Laces thirdPacket, - fourthPacket)); + fourthPacket), true); assertReadPacket(input, firstPacket); assertTrue((oggReader.getPageHeader().type & 0x02) == 0x02); @@ -111,12 +113,12 @@ public final class OggReaderTest extends TestCase { byte[] firstPacket = TestUtil.buildTestData(255, random); byte[] secondPacket = TestUtil.buildTestData(8, random); - FakeExtractorInput input = createInput( + FakeExtractorInput input = TestData.createInput( TestUtil.joinByteArrays( TestData.buildOggHeader(0x06, 0, 1000, 0x04), TestUtil.createByteArray(0xFF, 0x00, 0x00, 0x08), // Laces. firstPacket, - secondPacket)); + secondPacket), true); assertReadPacket(input, firstPacket); assertReadPacket(input, secondPacket); @@ -126,7 +128,7 @@ public final class OggReaderTest extends TestCase { public void testReadContinuedPacketOverTwoPages() throws Exception { byte[] firstPacket = TestUtil.buildTestData(518); - FakeExtractorInput input = createInput( + FakeExtractorInput input = TestData.createInput( TestUtil.joinByteArrays( // First page. TestData.buildOggHeader(0x02, 0, 1000, 0x02), @@ -135,7 +137,7 @@ public final class OggReaderTest extends TestCase { // Second page (continued packet). TestData.buildOggHeader(0x05, 10, 1001, 0x01), TestUtil.createByteArray(0x08), // Laces. - Arrays.copyOfRange(firstPacket, 510, 510 + 8))); + Arrays.copyOfRange(firstPacket, 510, 510 + 8)), true); assertReadPacket(input, firstPacket); assertTrue((oggReader.getPageHeader().type & 0x04) == 0x04); @@ -148,7 +150,7 @@ public final class OggReaderTest extends TestCase { public void testReadContinuedPacketOverFourPages() throws Exception { byte[] firstPacket = TestUtil.buildTestData(1028); - FakeExtractorInput input = createInput( + FakeExtractorInput input = TestData.createInput( TestUtil.joinByteArrays( // First page. TestData.buildOggHeader(0x02, 0, 1000, 0x02), @@ -165,7 +167,7 @@ public final class OggReaderTest extends TestCase { // Fourth page (continued packet). TestData.buildOggHeader(0x05, 10, 1003, 0x01), TestUtil.createByteArray(0x08), // Laces. - Arrays.copyOfRange(firstPacket, 510 + 255 + 255, 510 + 255 + 255 + 8))); + Arrays.copyOfRange(firstPacket, 510 + 255 + 255, 510 + 255 + 255 + 8)), true); assertReadPacket(input, firstPacket); assertTrue((oggReader.getPageHeader().type & 0x04) == 0x04); @@ -175,12 +177,27 @@ public final class OggReaderTest extends TestCase { assertReadEof(input); } + public void testReadDiscardContinuedPacketAtStart() throws Exception { + byte[] pageBody = TestUtil.buildTestData(256 + 8); + + FakeExtractorInput input = TestData.createInput( + TestUtil.joinByteArrays( + // Page with a continued packet at start. + TestData.buildOggHeader(0x01, 10, 1001, 0x03), + TestUtil.createByteArray(255, 1, 8), // Laces. + pageBody), true); + + // Expect the first partial packet to be discarded. + assertReadPacket(input, Arrays.copyOfRange(pageBody, 256, 256 + 8)); + assertReadEof(input); + } + public void testReadZeroSizedPacketsAtEndOfStream() throws Exception { byte[] firstPacket = TestUtil.buildTestData(8, random); byte[] secondPacket = TestUtil.buildTestData(8, random); byte[] thirdPacket = TestUtil.buildTestData(8, random); - FakeExtractorInput input = createInput( + FakeExtractorInput input = TestData.createInput( TestUtil.joinByteArrays( TestData.buildOggHeader(0x02, 0, 1000, 0x01), TestUtil.createByteArray(0x08), // Laces. @@ -190,7 +207,7 @@ public final class OggReaderTest extends TestCase { secondPacket, TestData.buildOggHeader(0x04, 0, 1002, 0x03), TestUtil.createByteArray(0x08, 0x00, 0x00), // Laces. - thirdPacket)); + thirdPacket), true); assertReadPacket(input, firstPacket); assertReadPacket(input, secondPacket); @@ -198,9 +215,127 @@ public final class OggReaderTest extends TestCase { assertReadEof(input); } - private static FakeExtractorInput createInput(byte[] data) { - return new FakeExtractorInput.Builder().setData(data).setSimulateIOErrors(true) - .setSimulateUnknownLength(true).setSimulatePartialReads(true).build(); + public void testSkipToPageOfGranule() throws IOException, InterruptedException { + byte[] packet = TestUtil.buildTestData(3 * 254, random); + FakeExtractorInput input = TestData.createInput( + TestUtil.joinByteArrays( + TestData.buildOggHeader(0x01, 20000, 1000, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet, + TestData.buildOggHeader(0x04, 40000, 1001, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet, + TestData.buildOggHeader(0x04, 60000, 1002, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet), false); + + // expect to be granule of the previous page returned as elapsedSamples + skipToPageOfGranule(input, 54000, 40000); + // expect to be at the start of the third page + assertEquals(2 * (30 + (3 * 254)), input.getPosition()); + } + + public void testSkipToPageOfGranulePreciseMatch() throws IOException, InterruptedException { + byte[] packet = TestUtil.buildTestData(3 * 254, random); + FakeExtractorInput input = TestData.createInput( + TestUtil.joinByteArrays( + TestData.buildOggHeader(0x01, 20000, 1000, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet, + TestData.buildOggHeader(0x04, 40000, 1001, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet, + TestData.buildOggHeader(0x04, 60000, 1002, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet), false); + + skipToPageOfGranule(input, 40000, 20000); + // expect to be at the start of the second page + assertEquals((30 + (3 * 254)), input.getPosition()); + } + + public void testSkipToPageOfGranuleAfterTargetPage() throws IOException, InterruptedException { + byte[] packet = TestUtil.buildTestData(3 * 254, random); + FakeExtractorInput input = TestData.createInput( + TestUtil.joinByteArrays( + TestData.buildOggHeader(0x01, 20000, 1000, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet, + TestData.buildOggHeader(0x04, 40000, 1001, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet, + TestData.buildOggHeader(0x04, 60000, 1002, 0x03), + TestUtil.createByteArray(254, 254, 254), // Laces. + packet), false); + + try { + skipToPageOfGranule(input, 10000, 20000); + fail(); + } catch (ParserException e) { + // ignored + } + assertEquals(0, input.getPosition()); + } + + private void skipToPageOfGranule(ExtractorInput input, long granule, + long elapsedSamplesExpected) throws IOException, InterruptedException { + while (true) { + try { + assertEquals(elapsedSamplesExpected, oggReader.skipToPageOfGranule(input, granule)); + return; + } catch (FakeExtractorInput.SimulatedIOException e) { + input.resetPeekPosition(); + } + } + } + + public void testReadGranuleOfLastPage() throws IOException, InterruptedException { + FakeExtractorInput input = TestData.createInput(TestUtil.joinByteArrays( + TestUtil.buildTestData(100, random), + TestData.buildOggHeader(0x00, 20000, 66, 3), + TestUtil.createByteArray(254, 254, 254), // laces + TestUtil.buildTestData(3 * 254, random), + TestData.buildOggHeader(0x00, 40000, 67, 3), + TestUtil.createByteArray(254, 254, 254), // laces + TestUtil.buildTestData(3 * 254, random), + TestData.buildOggHeader(0x05, 60000, 68, 3), + TestUtil.createByteArray(254, 254, 254), // laces + TestUtil.buildTestData(3 * 254, random) + ), false); + assertReadGranuleOfLastPage(input, 60000); + } + + public void testReadGranuleOfLastPageAfterLastHeader() throws IOException, InterruptedException { + FakeExtractorInput input = TestData.createInput(TestUtil.buildTestData(100, random), false); + try { + assertReadGranuleOfLastPage(input, 60000); + fail(); + } catch (EOFException e) { + // ignored + } + } + + public void testReadGranuleOfLastPageWithUnboundedLength() + throws IOException, InterruptedException { + FakeExtractorInput input = TestData.createInput(new byte[0], true); + try { + assertReadGranuleOfLastPage(input, 60000); + fail(); + } catch (IllegalArgumentException e) { + // ignored + } + } + + private void assertReadGranuleOfLastPage(FakeExtractorInput input, int expected) + throws IOException, InterruptedException { + while (true) { + try { + assertEquals(expected, oggReader.readGranuleOfLastPage(input)); + break; + } catch (FakeExtractorInput.SimulatedIOException e) { + // ignored + } + } } private void assertReadPacket(FakeExtractorInput extractorInput, byte[] expected) @@ -221,7 +356,7 @@ public final class OggReaderTest extends TestCase { while (true) { try { return oggReader.readPacket(input, scratch); - } catch (SimulatedIOException e) { + } catch (FakeExtractorInput.SimulatedIOException e) { // Ignore. } } diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggSeekerTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggSeekerTest.java new file mode 100644 index 0000000000..de0034fce4 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggSeekerTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2015 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.exoplayer.extractor.ogg; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.testutil.FakeExtractorInput; +import com.google.android.exoplayer.testutil.TestUtil; + +import junit.framework.TestCase; + +import java.io.IOException; + +/** + * Unit test for {@link OggSeeker}. + */ +public final class OggSeekerTest extends TestCase { + + private OggSeeker oggSeeker; + + @Override + public void setUp() throws Exception { + super.setUp(); + oggSeeker = new OggSeeker(); + oggSeeker.setup(1, 1); + } + + public void testSetupUnboundAudioLength() { + try { + new OggSeeker().setup(C.LENGTH_UNBOUNDED, 1000); + fail(); + } catch (IllegalArgumentException e) { + // ignored + } + } + + public void testSetupZeroOrNegativeTotalSamples() { + try { + new OggSeeker().setup(1000, 0); + fail(); + } catch (IllegalArgumentException e) { + // ignored + } + try { + new OggSeeker().setup(1000, -1000); + fail(); + } catch (IllegalArgumentException e) { + // ignored + } + } + + public void testGetNextSeekPositionSetupNotCalled() throws IOException, InterruptedException { + try { + new OggSeeker().getNextSeekPosition(1000, TestData.createInput(new byte[0], false)); + fail(); + } catch (IllegalStateException e) { + // ignored + } + } + + public void testGetNextSeekPositionMatch() throws IOException, InterruptedException { + long targetGranule = 100000; + long headerGranule = 52001; + FakeExtractorInput input = TestData.createInput(TestUtil.joinByteArrays( + TestData.buildOggHeader(0x00, headerGranule, 22, 2), + TestUtil.createByteArray(54, 55) // laces + ), false); + long expectedPosition = -1; + assertGetNextSeekPosition(expectedPosition, targetGranule, input); + } + + public void testGetNextSeekPositionTooHigh() throws IOException, InterruptedException { + long targetGranule = 100000; + long headerGranule = 200000; + FakeExtractorInput input = TestData.createInput(TestUtil.joinByteArrays( + TestData.buildOggHeader(0x00, headerGranule, 22, 2), + TestUtil.createByteArray(54, 55) // laces + ), false); + long doublePageSize = 2 * (input.getLength() + 54 + 55); + long expectedPosition = -doublePageSize + (targetGranule - headerGranule); + assertGetNextSeekPosition(expectedPosition, targetGranule, input); + } + + public void testGetNextSeekPositionTooHighDistanceLower48000() + throws IOException, InterruptedException { + long targetGranule = 199999; + long headerGranule = 200000; + FakeExtractorInput input = TestData.createInput(TestUtil.joinByteArrays( + TestData.buildOggHeader(0x00, headerGranule, 22, 2), + TestUtil.createByteArray(54, 55) // laces + ), false); + long doublePageSize = 2 * (input.getLength() + 54 + 55); + long expectedPosition = -doublePageSize - 1; + assertGetNextSeekPosition(expectedPosition, targetGranule, input); + } + + public void testGetNextSeekPositionTooLow() throws IOException, InterruptedException { + long headerGranule = 200000; + FakeExtractorInput input = TestData.createInput(TestUtil.joinByteArrays( + TestData.buildOggHeader(0x00, headerGranule, 22, 2), + TestUtil.createByteArray(54, 55) // laces + ), false); + long targetGranule = 300000; + long expectedPosition = -(27 + 2 + 54 + 55) + (targetGranule - headerGranule); + assertGetNextSeekPosition(expectedPosition, targetGranule, input); + } + + private void assertGetNextSeekPosition(long expectedPosition, long targetGranule, + FakeExtractorInput input) throws IOException, InterruptedException { + while (true) { + try { + assertEquals(expectedPosition, oggSeeker.getNextSeekPosition(targetGranule, input)); + break; + } catch (FakeExtractorInput.SimulatedIOException e) { + // ignored + } + } + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggUtilTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggUtilTest.java new file mode 100644 index 0000000000..b5c4ae08e8 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggUtilTest.java @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2015 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.exoplayer.extractor.ogg; + +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.testutil.FakeExtractorInput; +import com.google.android.exoplayer.testutil.FakeExtractorInput.SimulatedIOException; +import com.google.android.exoplayer.testutil.TestUtil; +import com.google.android.exoplayer.util.ParsableByteArray; + +import junit.framework.TestCase; + +import java.io.EOFException; +import java.io.IOException; +import java.util.Random; + +/** + * Unit test for {@link OggUtil}. + */ +public final class OggUtilTest extends TestCase { + + private Random random = new Random(0); + + public void testReadBits() throws Exception { + assertEquals(0, OggUtil.readBits((byte) 0x00, 2, 2)); + assertEquals(1, OggUtil.readBits((byte) 0x02, 1, 1)); + assertEquals(15, OggUtil.readBits((byte) 0xF0, 4, 4)); + assertEquals(1, OggUtil.readBits((byte) 0x80, 1, 7)); + } + + public void testPopulatePageHeader() throws IOException, InterruptedException { + FakeExtractorInput input = TestData.createInput(TestUtil.joinByteArrays( + TestData.buildOggHeader(0x01, 123456, 4, 2), + TestUtil.createByteArray(2, 2) + ), true); + OggUtil.PageHeader header = new OggUtil.PageHeader(); + ParsableByteArray byteArray = new ParsableByteArray(27 + 2); + populatePageHeader(input, header, byteArray, false); + + assertEquals(0x01, header.type); + assertEquals(27 + 2, header.headerSize); + assertEquals(4, header.bodySize); + assertEquals(2, header.pageSegmentCount); + assertEquals(123456, header.granulePosition); + assertEquals(4, header.pageSequenceNumber); + assertEquals(0x1000, header.streamSerialNumber); + assertEquals(0x100000, header.pageChecksum); + assertEquals(0, header.revision); + } + + public void testPopulatePageHeaderQuiteOnExceptionLessThan27Bytes() + throws IOException, InterruptedException { + FakeExtractorInput input = TestData.createInput(TestUtil.createByteArray(2, 2), false); + OggUtil.PageHeader header = new OggUtil.PageHeader(); + ParsableByteArray byteArray = new ParsableByteArray(27 + 2); + assertFalse(populatePageHeader(input, header, byteArray, true)); + } + + public void testPopulatePageHeaderQuiteOnExceptionNotOgg() + throws IOException, InterruptedException { + byte[] headerBytes = TestUtil.joinByteArrays( + TestData.buildOggHeader(0x01, 123456, 4, 2), + TestUtil.createByteArray(2, 2) + ); + // change from 'O' to 'o' + headerBytes[0] = 'o'; + FakeExtractorInput input = TestData.createInput(headerBytes, false); + OggUtil.PageHeader header = new OggUtil.PageHeader(); + ParsableByteArray byteArray = new ParsableByteArray(27 + 2); + assertFalse(populatePageHeader(input, header, byteArray, true)); + } + + public void testPopulatePageHeaderQuiteOnExceptionWrongRevision() + throws IOException, InterruptedException { + byte[] headerBytes = TestUtil.joinByteArrays( + TestData.buildOggHeader(0x01, 123456, 4, 2), + TestUtil.createByteArray(2, 2) + ); + // change revision from 0 to 1 + headerBytes[4] = 0x01; + FakeExtractorInput input = TestData.createInput(headerBytes, false); + OggUtil.PageHeader header = new OggUtil.PageHeader(); + ParsableByteArray byteArray = new ParsableByteArray(27 + 2); + assertFalse(populatePageHeader(input, header, byteArray, true)); + } + + private boolean populatePageHeader(FakeExtractorInput input, OggUtil.PageHeader header, + ParsableByteArray byteArray, boolean quite) throws IOException, InterruptedException { + while (true) { + try { + return OggUtil.populatePageHeader(input, header, byteArray, quite); + } catch (SimulatedIOException e) { + // ignored + } + } + } + + public void testSkipToNextPage() throws Exception { + FakeExtractorInput extractorInput = createInput( + TestUtil.joinByteArrays( + TestUtil.buildTestData(4000, random), + new byte[]{'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(4000, random) + ), false); + skipToNextPage(extractorInput); + assertEquals(4000, extractorInput.getPosition()); + } + + public void testSkipToNextPageUnbounded() throws Exception { + FakeExtractorInput extractorInput = createInput( + TestUtil.joinByteArrays( + TestUtil.buildTestData(4000, random), + new byte[]{'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(4000, random) + ), true); + skipToNextPage(extractorInput); + assertEquals(4000, extractorInput.getPosition()); + } + + public void testSkipToNextPageOverlap() throws Exception { + FakeExtractorInput extractorInput = createInput( + TestUtil.joinByteArrays( + TestUtil.buildTestData(2046, random), + new byte[]{'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(4000, random) + ), false); + skipToNextPage(extractorInput); + assertEquals(2046, extractorInput.getPosition()); + } + + public void testSkipToNextPageOverlapUnbounded() throws Exception { + FakeExtractorInput extractorInput = createInput( + TestUtil.joinByteArrays( + TestUtil.buildTestData(2046, random), + new byte[]{'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(4000, random) + ), true); + skipToNextPage(extractorInput); + assertEquals(2046, extractorInput.getPosition()); + } + + public void testSkipToNextPageInputShorterThanPeekLength() throws Exception { + FakeExtractorInput extractorInput = createInput( + TestUtil.joinByteArrays( + new byte[]{'x', 'O', 'g', 'g', 'S'} + ), false); + skipToNextPage(extractorInput); + assertEquals(1, extractorInput.getPosition()); + } + + public void testSkipToNextPageNoMatch() throws Exception { + FakeExtractorInput extractorInput = createInput(new byte[]{'g', 'g', 'S', 'O', 'g', 'g'}, + false); + try { + skipToNextPage(extractorInput); + fail(); + } catch (EOFException e) { + // expected + } + } + + private static void skipToNextPage(ExtractorInput extractorInput) + throws IOException, InterruptedException { + while (true) { + try { + OggUtil.skipToNextPage(extractorInput); + break; + } catch (SimulatedIOException e) { /* ignored */ } + } + } + + private static FakeExtractorInput createInput(byte[] data, boolean simulateUnknownLength) { + return new FakeExtractorInput.Builder().setData(data).setSimulateIOErrors(true) + .setSimulateUnknownLength(simulateUnknownLength).setSimulatePartialReads(true).build(); + } +} + diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractorTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractorTest.java index 45b4ad63a6..46f59200c3 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractorTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractorTest.java @@ -43,15 +43,36 @@ public final class OggVorbisExtractorTest extends TestCase { public void testSniff() throws Exception { byte[] data = TestUtil.joinByteArrays( TestData.buildOggHeader(0x02, 0, 1000, 0x02), - TestUtil.createByteArray(120, 120)); // Laces + TestUtil.createByteArray(120, 120), // Laces + new byte[]{0x01, 'v', 'o', 'r', 'b', 'i', 's'}); assertTrue(sniff(createInput(data))); } - public void testSniffFails() throws Exception { + public void testSniffFailsOpusFile() throws Exception { + byte[] data = TestUtil.joinByteArrays( + TestData.buildOggHeader(0x02, 0, 1000, 0x00), + new byte[]{'O', 'p', 'u', 's'}); + assertFalse(sniff(createInput(data))); + } + + public void testSniffFailsInvalidOggHeader() throws Exception { byte[] data = TestData.buildOggHeader(0x00, 0, 1000, 0x00); assertFalse(sniff(createInput(data))); } + public void testSniffInvalidVorbisHeader() throws Exception { + byte[] data = TestUtil.joinByteArrays( + TestData.buildOggHeader(0x02, 0, 1000, 0x02), + TestUtil.createByteArray(120, 120), // Laces + new byte[]{0x01, 'X', 'o', 'r', 'b', 'i', 's'}); + assertFalse(sniff(createInput(data))); + } + + public void testSniffFailsEOF() throws Exception { + byte[] data = TestData.buildOggHeader(0x02, 0, 1000, 0x00); + assertFalse(sniff(createInput(data))); + } + public void testAppendNumberOfSamples() throws Exception { ParsableByteArray buffer = new ParsableByteArray(4); buffer.setLimit(0); diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/TestData.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/TestData.java index caf97e32a3..014438ee1f 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/TestData.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/TestData.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer.extractor.ogg; +import com.google.android.exoplayer.testutil.FakeExtractorInput; import com.google.android.exoplayer.testutil.TestUtil; /** @@ -22,6 +23,11 @@ import com.google.android.exoplayer.testutil.TestUtil; */ /* package */ final class TestData { + /* package */ static FakeExtractorInput createInput(byte[] data, boolean simulateUnkownLength) { + return new FakeExtractorInput.Builder().setData(data).setSimulateIOErrors(true) + .setSimulateUnknownLength(simulateUnkownLength).setSimulatePartialReads(true).build(); + } + public static byte[] buildOggHeader(int headerType, long granule, int pageSequenceCounter, int pageSegmentCount) { return TestUtil.createByteArray( @@ -46,7 +52,7 @@ import com.google.android.exoplayer.testutil.TestUtil; (pageSequenceCounter >> 24) & 0xFF, 0x00, // LSB of page checksum. 0x00, - 0x00, + 0x10, 0x00, // MSB of page checksum. pageSegmentCount); } diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/VorbisUtilTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/VorbisUtilTest.java index 7bd17fb65a..07b8130026 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/VorbisUtilTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/VorbisUtilTest.java @@ -38,13 +38,6 @@ public final class VorbisUtilTest extends TestCase { assertEquals(0, VorbisUtil.iLog(-122)); } - public void testReadBits() throws Exception { - assertEquals(0, VorbisUtil.readBits((byte) 0x00, 2, 2)); - assertEquals(1, VorbisUtil.readBits((byte) 0x02, 1, 1)); - assertEquals(15, VorbisUtil.readBits((byte) 0xF0, 4, 4)); - assertEquals(1, VorbisUtil.readBits((byte) 0x80, 1, 7)); - } - public void testReadIdHeader() throws Exception { byte[] data = TestData.getIdentificationHeaderData(); ParsableByteArray headerData = new ParsableByteArray(data, data.length); @@ -95,4 +88,45 @@ public final class VorbisUtilTest extends TestCase { assertEquals(0, modes[1].windowType); } + public void testVerifyVorbisHeaderCapturePattern() throws ParserException { + ParsableByteArray header = new ParsableByteArray( + new byte[]{0x01, 'v', 'o', 'r', 'b', 'i', 's'}); + assertEquals(true, VorbisUtil.verifyVorbisHeaderCapturePattern(0x01, header, false)); + } + + public void testVerifyVorbisHeaderCapturePatternInvalidHeader() throws ParserException { + ParsableByteArray header = new ParsableByteArray( + new byte[]{0x01, 'v', 'o', 'r', 'b', 'i', 's'}); + try { + VorbisUtil.verifyVorbisHeaderCapturePattern(0x99, header, false); + fail(); + } catch (ParserException e) { + assertEquals("expected header type 99", e.getMessage()); + } + } + + public void testVerifyVorbisHeaderCapturePatternInvalidHeaderQuite() throws ParserException { + ParsableByteArray header = new ParsableByteArray( + new byte[]{0x01, 'v', 'o', 'r', 'b', 'i', 's'}); + assertFalse(VorbisUtil.verifyVorbisHeaderCapturePattern(0x99, header, true)); + } + + public void testVerifyVorbisHeaderCapturePatternInvalidPattern() throws ParserException { + ParsableByteArray header = new ParsableByteArray( + new byte[]{0x01, 'x', 'v', 'o', 'r', 'b', 'i', 's'}); + try { + VorbisUtil.verifyVorbisHeaderCapturePattern(0x01, header, false); + fail(); + } catch (ParserException e) { + assertEquals("expected characters 'vorbis'", e.getMessage()); + } + } + + public void testVerifyVorbisHeaderCapturePatternQuiteInvalidPatternQuite() + throws ParserException { + ParsableByteArray header = new ParsableByteArray( + new byte[]{0x01, 'x', 'v', 'o', 'r', 'b', 'i', 's'}); + assertFalse(VorbisUtil.verifyVorbisHeaderCapturePattern(0x01, header, true)); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggReader.java index b913aed4c9..3fb2ed473a 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggReader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggReader.java @@ -15,11 +15,12 @@ */ package com.google.android.exoplayer.extractor.ogg; +import com.google.android.exoplayer.C; import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.ogg.OggUtil.PacketInfoHolder; import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.ParsableByteArray; -import com.google.android.exoplayer.util.Util; import java.io.IOException; @@ -28,12 +29,14 @@ import java.io.IOException; */ /* package */ final class OggReader { - private static final String CAPTURE_PATTERN_PAGE = "OggS"; + public static final int OGG_MAX_SEGMENT_SIZE = 255; - private final PageHeader pageHeader = new PageHeader(); + private final OggUtil.PageHeader pageHeader = new OggUtil.PageHeader(); private final ParsableByteArray headerArray = new ParsableByteArray(27 + 255); + private final PacketInfoHolder holder = new PacketInfoHolder(); private int currentSegmentIndex = -1; + private long elapsedSamples; /** * Resets this reader. @@ -65,37 +68,99 @@ import java.io.IOException; while (!packetComplete) { if (currentSegmentIndex < 0) { // We're at the start of a page. - if (!populatePageHeader(input, pageHeader, headerArray, false)) { + if (!OggUtil.populatePageHeader(input, pageHeader, headerArray, true)) { return false; } - currentSegmentIndex = 0; - } - - int packetSize = 0; - int segmentIndex = currentSegmentIndex; - // add up packetSize from laces - while (segmentIndex < pageHeader.pageSegmentCount) { - int segmentLength = pageHeader.laces[segmentIndex++]; - packetSize += segmentLength; - if (segmentLength != 255) { - // packets end at first lace < 255 - break; + int segmentIndex = 0; + int bytesToSkip = pageHeader.headerSize; + if ((pageHeader.type & 0x01) == 0x01 && packetArray.limit() == 0) { + // After seeking, the first packet may be the remainder + // part of a continued packet which has to be discarded. + OggUtil.calculatePacketSize(pageHeader, segmentIndex, holder); + segmentIndex += holder.segmentCount; + bytesToSkip += holder.size; } + input.skipFully(bytesToSkip); + currentSegmentIndex = segmentIndex; } - if (packetSize > 0) { - input.readFully(packetArray.data, packetArray.limit(), packetSize); - packetArray.setLimit(packetArray.limit() + packetSize); + OggUtil.calculatePacketSize(pageHeader, currentSegmentIndex, holder); + int segmentIndex = currentSegmentIndex + holder.segmentCount; + if (holder.size > 0) { + input.readFully(packetArray.data, packetArray.limit(), holder.size); + packetArray.setLimit(packetArray.limit() + holder.size); packetComplete = pageHeader.laces[segmentIndex - 1] != 255; } // advance now since we are sure reading didn't throw an exception - currentSegmentIndex = segmentIndex == pageHeader.pageSegmentCount ? -1 : segmentIndex; + currentSegmentIndex = segmentIndex == pageHeader.pageSegmentCount ? -1 + : segmentIndex; } return true; } /** - * Returns the {@link OggReader.PageHeader} of the current page. The header might not have been + * 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. + * + * @param input The {@link ExtractorInput} to read from. + * @return the total number of samples of this input. + * @throws IOException thrown if reading from the input fails. + * @throws InterruptedException thrown if interrupted while reading from the input. + */ + public long readGranuleOfLastPage(ExtractorInput input) + throws IOException, InterruptedException { + Assertions.checkArgument(input.getLength() != C.LENGTH_UNBOUNDED); // never read forever! + OggUtil.skipToNextPage(input); + pageHeader.reset(); + while ((pageHeader.type & 0x04) != 0x04) { + if (pageHeader.bodySize > 0) { + input.skipFully(pageHeader.bodySize); + } + OggUtil.populatePageHeader(input, pageHeader, headerArray, false); + input.skipFully(pageHeader.headerSize); + } + return pageHeader.granulePosition; + } + + /** + * Skips to the position of the start of the page containing the {@code targetGranule} and + * returns the elapsed samples which is the granule of the page previous to the target page. + *

+ * Note that the position of the {@code input} must be before the start of the page previous to + * the page containing the targetGranule to get the correct number of elapsed samples. + * Which is in short like: {@code pos(input) <= pos(targetPage.pageSequence - 1)}. + * + * @param input the {@link ExtractorInput} to read from. + * @param targetGranule the target granule (number of frames per channel). + * @return the number of elapsed samples at the start of the target page. + * @throws ParserException thrown if populating the page header fails. + * @throws IOException thrown if reading from the input fails. + * @throws InterruptedException thrown if interrupted while reading from the input. + */ + public long skipToPageOfGranule(ExtractorInput input, long targetGranule) + throws IOException, InterruptedException { + OggUtil.skipToNextPage(input); + OggUtil.populatePageHeader(input, pageHeader, headerArray, false); + while (pageHeader.granulePosition < targetGranule) { + input.skipFully(pageHeader.headerSize + pageHeader.bodySize); + // Store in a member field to be able to resume after IOExceptions. + elapsedSamples = pageHeader.granulePosition; + // Peek next header. + OggUtil.populatePageHeader(input, pageHeader, headerArray, false); + } + if (elapsedSamples == 0) { + throw new ParserException(); + } + input.resetPeekPosition(); + long returnValue = elapsedSamples; + // Reset member state. + elapsedSamples = 0; + currentSegmentIndex = -1; + return returnValue; + } + + /** + * Returns the {@link OggUtil.PageHeader} of the current page. The header might not have been * populated if the first packet has yet to be read. *

* Note that there is only a single instance of {@code OggReader.PageHeader} which is mutable. @@ -104,93 +169,8 @@ import java.io.IOException; * * @return the {@code PageHeader} of the current page or {@code null}. */ - public PageHeader getPageHeader() { + public OggUtil.PageHeader getPageHeader() { return pageHeader; } - /** - * Reads/peeks an Ogg page header and stores the data in the {@code header} object passed - * as argument. - * - * @param input the {@link ExtractorInput} to read from. - * @param header the {@link PageHeader} to read from. - * @param scratch a scratch array temporary use. - * @param peek pass {@code true} if data should only be peeked from current peek position. - * @return {@code true} if the read was successful. {@code false} if the end of the - * input was encountered having read no data. - * @throws IOException thrown if reading data fails or the stream is invalid. - * @throws InterruptedException thrown if thread is interrupted when reading/peeking. - */ - public static boolean populatePageHeader(ExtractorInput input, PageHeader header, - ParsableByteArray scratch, boolean peek) throws IOException, InterruptedException { - - scratch.reset(); - header.reset(); - if (!input.peekFully(scratch.data, 0, 27, true)) { - return false; - } - if (scratch.readUnsignedInt() != Util.getIntegerCodeForString(CAPTURE_PATTERN_PAGE)) { - throw new ParserException("expected OggS capture pattern at begin of page"); - } - - header.revision = scratch.readUnsignedByte(); - if (header.revision != 0x00) { - throw new ParserException("unsupported bit stream revision"); - } - header.type = scratch.readUnsignedByte(); - - header.granulePosition = scratch.readLittleEndianLong(); - header.streamSerialNumber = scratch.readLittleEndianUnsignedInt(); - header.pageSequenceNumber = scratch.readLittleEndianUnsignedInt(); - header.pageChecksum = scratch.readLittleEndianUnsignedInt(); - header.pageSegmentCount = scratch.readUnsignedByte(); - - scratch.reset(); - // calculate total size of header including laces - header.headerSize = 27 + header.pageSegmentCount; - input.peekFully(scratch.data, 0, header.pageSegmentCount); - for (int i = 0; i < header.pageSegmentCount; i++) { - header.laces[i] = scratch.readUnsignedByte(); - header.bodySize += header.laces[i]; - } - if (!peek) { - input.skipFully(header.headerSize); - } - return true; - } - - /** - * Data object to store header information. Be aware that {@code laces.length} is always 255. - * Instead use {@code pageSegmentCount} to iterate. - */ - public static final class PageHeader { - - public int revision; - public int type; - public long granulePosition; - public long streamSerialNumber; - public long pageSequenceNumber; - public long pageChecksum; - public int pageSegmentCount; - public int headerSize; - public int bodySize; - public int[] laces = new int[255]; - - /** - * Resets all primitive member fields to zero. - */ - public void reset() { - revision = 0; - type = 0; - granulePosition = 0; - streamSerialNumber = 0; - pageSequenceNumber = 0; - pageChecksum = 0; - pageSegmentCount = 0; - headerSize = 0; - bodySize = 0; - } - - } - } diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggSeeker.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggSeeker.java new file mode 100644 index 0000000000..7466c8d2e8 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggSeeker.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2015 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.exoplayer.extractor.ogg; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.ParsableByteArray; + +import java.io.IOException; + +/** + * Used to seek in an Ogg stream. + */ +/* package */ final class OggSeeker { + + private static final int MATCH_RANGE = 72000; + + private final OggUtil.PageHeader pageHeader = new OggUtil.PageHeader(); + private final ParsableByteArray headerArray = new ParsableByteArray(27 + 255); + private long audioDataLength = C.LENGTH_UNBOUNDED; + private long totalSamples; + + /** + * Setup the seeker with the data it needs to to an educated guess of seeking positions. + * + * @param audioDataLength the length of the audio data (total bytes - header bytes). + * @param totalSamples the total number of samples of audio data. + */ + public void setup(long audioDataLength, long totalSamples) { + Assertions.checkArgument(audioDataLength > 0 && totalSamples > 0); + this.audioDataLength = audioDataLength; + this.totalSamples = totalSamples; + } + + /** + * Resets this {@code OggSeeker}. + */ + public void reset() { + pageHeader.reset(); + headerArray.reset(); + } + + /** + * Returns a position converging to the {@code targetGranule} to which the {@link ExtractorInput} + * has to seek and then be passed for another call until -1 is return. If -1 is returned the + * input is at a position which is before the start of the page before the target page and at + * which it is sensible to just skip pages to the target granule and pre-roll instead of doing + * another seek request. + * + * @param targetGranule the target granule position to seek to. + * @param input the {@link ExtractorInput} to read from. + * @return the position to seek the {@link ExtractorInput} to for a next call or -1 if it's close + * enough to skip to the target page. + * @throws IOException thrown if reading from the input fails. + * @throws InterruptedException thrown if interrupted while reading from the input. + */ + public long getNextSeekPosition(long targetGranule, ExtractorInput input) + throws IOException, InterruptedException { + Assertions.checkState(audioDataLength != C.LENGTH_UNBOUNDED && totalSamples != 0); + OggUtil.populatePageHeader(input, pageHeader, headerArray, false); + long granuleDistance = targetGranule - pageHeader.granulePosition; + if (granuleDistance <= 0 || granuleDistance > MATCH_RANGE) { + // estimated position too high or too low + long offset = (pageHeader.bodySize + pageHeader.headerSize) + * (granuleDistance <= 0 ? 2 : 1); + return input.getPosition() - offset + (granuleDistance * audioDataLength / totalSamples); + } + // position accepted (below target granule and within MATCH_RANGE) + input.resetPeekPosition(); + return -1; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java new file mode 100644 index 0000000000..d62ba6ef42 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2015 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.exoplayer.extractor.ogg; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.util.ParsableByteArray; +import com.google.android.exoplayer.util.Util; + +import java.io.EOFException; +import java.io.IOException; + +/** + * Utility methods for reading ogg streams. + */ +/* package */ final class OggUtil { + + private static final int TYPE_OGGS = Util.getIntegerCodeForString("OggS"); + + /** + * Reads an int of {@code length} bits from {@code src} starting at + * {@code leastSignificantBitIndex}. + * + * @param src the {@code byte} to read from. + * @param length the length in bits of the int to read. + * @param leastSignificantBitIndex the index of the least significant bit of the int to read. + * @return the int value read. + */ + public static int readBits(byte src, int length, int leastSignificantBitIndex) { + return (src >> leastSignificantBitIndex) & (255 >>> (8 - length)); + } + + /** + * Skips to the next page. + * + * @param input The {@code ExtractorInput} to skip to the next page. + * @throws IOException thrown if peeking/reading from the input fails. + * @throws InterruptedException thrown if interrupted while peeking/reading from the input. + */ + public static void skipToNextPage(ExtractorInput input) + throws IOException, InterruptedException { + + byte[] buffer = new byte[2048]; + int peekLength = buffer.length; + while (true) { + if (input.getLength() != C.LENGTH_UNBOUNDED + && input.getPosition() + peekLength > input.getLength()) { + // Make sure to not peek beyond the end of the input. + peekLength = (int) (input.getLength() - input.getPosition()); + if (peekLength < 4) { + // Not found until eof. + throw new EOFException(); + } + } + 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; + } + } + // Overlap by not skipping the entire peekLength. + input.skipFully(peekLength - 3); + } + } + + /** + * Peeks an Ogg page header and stores the data in the {@code header} object passed + * as argument. + * + * @param input the {@link ExtractorInput} to read from. + * @param header the {@link PageHeader} to read from. + * @param scratch a scratch array temporary use. + * @param quite if {@code true} no Exceptions are thrown but {@code false} is return if something + * goes wrong. + * @return {@code true} if the read was successful. {@code false} if the end of the + * input was encountered having read no data. + * @throws IOException thrown if reading data fails or the stream is invalid. + * @throws InterruptedException thrown if thread is interrupted when reading/peeking. + */ + public static boolean populatePageHeader(ExtractorInput input, PageHeader header, + ParsableByteArray scratch, boolean quite) throws IOException, InterruptedException { + + scratch.reset(); + header.reset(); + boolean hasEnoughBytes = input.getLength() == C.LENGTH_UNBOUNDED + || input.getLength() - input.getPeekPosition() >= 27; + if (!hasEnoughBytes || !input.peekFully(scratch.data, 0, 27, true)) { + if (quite) { + return false; + } else { + throw new EOFException(); + } + } + if (scratch.readUnsignedInt() != TYPE_OGGS) { + if (quite) { + return false; + } else { + throw new ParserException("expected OggS capture pattern at begin of page"); + } + } + + header.revision = scratch.readUnsignedByte(); + if (header.revision != 0x00) { + if (quite) { + return false; + } else { + throw new ParserException("unsupported bit stream revision"); + } + } + header.type = scratch.readUnsignedByte(); + + header.granulePosition = scratch.readLittleEndianLong(); + header.streamSerialNumber = scratch.readLittleEndianUnsignedInt(); + header.pageSequenceNumber = scratch.readLittleEndianUnsignedInt(); + header.pageChecksum = scratch.readLittleEndianUnsignedInt(); + header.pageSegmentCount = scratch.readUnsignedByte(); + + scratch.reset(); + // calculate total size of header including laces + header.headerSize = 27 + header.pageSegmentCount; + input.peekFully(scratch.data, 0, header.pageSegmentCount); + for (int i = 0; i < header.pageSegmentCount; i++) { + header.laces[i] = scratch.readUnsignedByte(); + header.bodySize += header.laces[i]; + } + return true; + } + + /** + * Calculates the size of the packet starting from {@code startSegmentIndex}. + * + * @param header the {@link PageHeader} with laces. + * @param startSegmentIndex the index of the first segment of the packet. + * @param holder a position holder to store the resulting size value. + */ + public static void calculatePacketSize(PageHeader header, int startSegmentIndex, + PacketInfoHolder holder) { + holder.segmentCount = 0; + holder.size = 0; + while (startSegmentIndex + holder.segmentCount < header.pageSegmentCount) { + int segmentLength = header.laces[startSegmentIndex + holder.segmentCount++]; + holder.size += segmentLength; + if (segmentLength != 255) { + // packets end at first lace < 255 + break; + } + } + } + + /** + * Data object to store header information. Be aware that {@code laces.length} is always 255. + * Instead use {@code pageSegmentCount} to iterate. + */ + public static final class PageHeader { + + public int revision; + public int type; + public long granulePosition; + public long streamSerialNumber; + public long pageSequenceNumber; + public long pageChecksum; + public int pageSegmentCount; + public int headerSize; + public int bodySize; + public final int[] laces = new int[255]; + + /** + * Resets all primitive member fields to zero. + */ + public void reset() { + revision = 0; + type = 0; + granulePosition = 0; + streamSerialNumber = 0; + pageSequenceNumber = 0; + pageChecksum = 0; + pageSegmentCount = 0; + headerSize = 0; + bodySize = 0; + } + + } + + /** + * Holds size and number of segments of a packet. + */ + public static class PacketInfoHolder { + public int size; + public int segmentCount; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractor.java index 7daeb7e2ea..22b6b6db23 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggVorbisExtractor.java @@ -25,27 +25,21 @@ import com.google.android.exoplayer.extractor.PositionHolder; import com.google.android.exoplayer.extractor.SeekMap; import com.google.android.exoplayer.extractor.TrackOutput; import com.google.android.exoplayer.extractor.ogg.VorbisUtil.Mode; -import com.google.android.exoplayer.extractor.ogg.VorbisUtil.VorbisIdHeader; import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.ParsableByteArray; -import android.util.Log; - import java.io.IOException; import java.util.ArrayList; /** * {@link Extractor} to extract Vorbis data out of Ogg byte stream. */ -public final class OggVorbisExtractor implements Extractor { - - private static final String TAG = "OggVorbisExtractor"; - - private static final int OGG_MAX_SEGMENT_SIZE = 255; +public final class OggVorbisExtractor implements Extractor, SeekMap { private final ParsableByteArray scratch = new ParsableByteArray( - new byte[OGG_MAX_SEGMENT_SIZE * 255], 0); - private final OggReader oggReader = new OggReader(); + new byte[OggReader.OGG_MAX_SEGMENT_SIZE * 255], 0); + + private final OggReader oggReader = new OggReader(); private TrackOutput trackOutput; private VorbisSetup vorbisSetup; @@ -53,36 +47,48 @@ public final class OggVorbisExtractor implements Extractor { private long elapsedSamples; private boolean seenFirstAudioPacket; + private final OggSeeker oggSeeker = new OggSeeker(); + private long targetGranule = -1; + + private ExtractorOutput extractorOutput; private VorbisUtil.VorbisIdHeader vorbisIdHeader; private VorbisUtil.CommentHeader commentHeader; + private long inputLength; + private long audioStartPosition; + private long totalSamples; + private long durationUs; @Override public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { try { - OggReader.PageHeader header = new OggReader.PageHeader(); - OggReader.populatePageHeader(input, header, scratch, true); - if ((header.type & 0x02) != 0x02) { - throw new ParserException("expected page to be first page of a logical stream"); + OggUtil.PageHeader header = new OggUtil.PageHeader(); + if (!OggUtil.populatePageHeader(input, header, scratch, true) + || (header.type & 0x02) != 0x02 || header.bodySize < 7) { + return false; } - input.resetPeekPosition(); + scratch.reset(); + input.peekFully(scratch.data, 0, 7); + return VorbisUtil.verifyVorbisHeaderCapturePattern(0x01, scratch, true); } catch (ParserException e) { - Log.e(TAG, e.getMessage()); - return false; + // does not happen + } finally { + input.resetPeekPosition(); + scratch.reset(); } - return true; + return false; } @Override public void init(ExtractorOutput output) { trackOutput = output.track(0); output.endTracks(); - output.seekMap(new SeekMap.Unseekable(C.UNKNOWN_TIME_US)); + extractorOutput = output; } @Override public void seek() { oggReader.reset(); - previousPacketBlockSize = -1; + previousPacketBlockSize = 0; elapsedSamples = 0; seenFirstAudioPacket = false; scratch.reset(); @@ -92,35 +98,77 @@ public final class OggVorbisExtractor implements Extractor { public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException { - if (vorbisSetup == null) { - vorbisSetup = readSetupHeaders(input, scratch); - VorbisIdHeader idHeader = vorbisSetup.idHeader; - ArrayList codecInitializationData = new ArrayList<>(); - codecInitializationData.clear(); - codecInitializationData.add(idHeader.data); - codecInitializationData.add(vorbisSetup.setupHeaderData); - trackOutput.format(Format.createAudioSampleFormat(null, MimeTypes.AUDIO_VORBIS, - idHeader.bitrateNominal, OGG_MAX_SEGMENT_SIZE * 255, idHeader.channels, - idHeader.sampleRate, codecInitializationData, null)); + // Setup. + if (totalSamples == 0) { + if (vorbisSetup == null) { + inputLength = input.getLength(); + vorbisSetup = readSetupHeaders(input, scratch); + audioStartPosition = input.getPosition(); + // Output the format. + ArrayList codecInitialisationData = new ArrayList<>(); + codecInitialisationData.add(vorbisSetup.idHeader.data); + codecInitialisationData.add(vorbisSetup.setupHeaderData); + trackOutput.format(Format.createAudioSampleFormat(null, MimeTypes.AUDIO_VORBIS, + vorbisSetup.idHeader.bitrateNominal, OggReader.OGG_MAX_SEGMENT_SIZE * 255, + vorbisSetup.idHeader.channels, (int) vorbisSetup.idHeader.sampleRate, + codecInitialisationData, null)); + if (inputLength == C.LENGTH_UNBOUNDED) { + // If the length is unbounded, we cannot determine the duration or seek. + totalSamples = -1; + durationUs = C.LENGTH_UNBOUNDED; + extractorOutput.seekMap(this); + return RESULT_CONTINUE; + } + // Seek to just before the last page of stream to get the duration. + seekPosition.position = input.getLength() - 8000; + return RESULT_SEEK; + } + + totalSamples = oggReader.readGranuleOfLastPage(input); + durationUs = totalSamples * C.MICROS_PER_SECOND / vorbisSetup.idHeader.sampleRate; + oggSeeker.setup(inputLength - audioStartPosition, totalSamples); + extractorOutput.seekMap(this); + // Seek back to resume from where we finished reading vorbis headers. + seekPosition.position = audioStartPosition; + return RESULT_SEEK; } + + // Seeking requested. + if (!seenFirstAudioPacket && targetGranule > -1) { + OggUtil.skipToNextPage(input); + long position = oggSeeker.getNextSeekPosition(targetGranule, input); + if (position != -1) { + seekPosition.position = position; + return RESULT_SEEK; + } else { + elapsedSamples = oggReader.skipToPageOfGranule(input, targetGranule); + previousPacketBlockSize = vorbisIdHeader.blockSize0; + // We're never at the first packet after seeking. + seenFirstAudioPacket = true; + oggSeeker.reset(); + } + } + + // Playback. if (oggReader.readPacket(input, scratch)) { - // if this is an audio packet... + // If this is an audio packet... if ((scratch.data[0] & 0x01) != 1) { - // ... we need to decode the block size + // ... Then we need to decode the block size int packetBlockSize = decodeBlockSize(scratch.data[0], vorbisSetup); - // a packet contains samples produced from overlapping the previous and current frame data - // (https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-350001.3.2) - int samplesInPacket = seenFirstAudioPacket ? (packetBlockSize + previousPacketBlockSize) / 4 - : 0; - // codec expects the number of samples appended to audio data - appendNumberOfSamples(scratch, samplesInPacket); - - // calculate time and send audio data to codec - long timeUs = elapsedSamples * C.MICROS_PER_SECOND / vorbisSetup.idHeader.sampleRate; - trackOutput.sampleData(scratch, scratch.limit()); - trackOutput.sampleMetadata(timeUs, C.SAMPLE_FLAG_SYNC, scratch.limit(), 0, null); - - // update state in members for next iteration + // A packet contains samples produced from overlapping the previous and current frame data + // (https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-350001.3.2). + int samplesInPacket = seenFirstAudioPacket + ? ((packetBlockSize + previousPacketBlockSize) / 4) : 0; + if (elapsedSamples + samplesInPacket >= targetGranule) { + // Codec expects the number of samples appended to audio data. + appendNumberOfSamples(scratch, samplesInPacket); + // Calculate time and send audio data to codec. + long timeUs = elapsedSamples * C.MICROS_PER_SECOND / vorbisSetup.idHeader.sampleRate; + trackOutput.sampleData(scratch, scratch.limit()); + trackOutput.sampleMetadata(timeUs, C.SAMPLE_FLAG_SYNC, scratch.limit(), 0, null); + targetGranule = -1; + } + // Update state in members for next iteration. seenFirstAudioPacket = true; elapsedSamples += samplesInPacket; previousPacketBlockSize = packetBlockSize; @@ -176,7 +224,7 @@ public final class OggVorbisExtractor implements Extractor { private static int decodeBlockSize(byte firstByteOfAudioPacket, VorbisSetup vorbisSetup) { // read modeNumber (https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-730004.3.1) - int modeNumber = VorbisUtil.readBits(firstByteOfAudioPacket, vorbisSetup.iLogModes, 1); + int modeNumber = OggUtil.readBits(firstByteOfAudioPacket, vorbisSetup.iLogModes, 1); int currentBlockSize; if (!vorbisSetup.modes[modeNumber].blockFlag) { currentBlockSize = vorbisSetup.idHeader.blockSize0; @@ -186,6 +234,31 @@ public final class OggVorbisExtractor implements Extractor { return currentBlockSize; } + // SeekMap implementation. + + @Override + public boolean isSeekable() { + return inputLength != C.LENGTH_UNBOUNDED; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public long getPosition(long timeUs) { + if (timeUs == 0) { + targetGranule = -1; + return audioStartPosition; + } + targetGranule = vorbisSetup.idHeader.sampleRate * timeUs / C.MICROS_PER_SECOND; + return Math.max(audioStartPosition, ((inputLength - audioStartPosition) * timeUs + / durationUs) - 4000); + } + + // Internal classes. + /** * Class to hold all data read from Vorbis setup headers. */ diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java index 183c6c3adb..a325e4e03e 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java @@ -46,19 +46,6 @@ import java.util.Arrays; return val; } - /** - * Reads an int of {@code length} bits from {@code src} starting at - * {@code leastSignificantBitIndex}. - * - * @param src the {@code byte} to read from. - * @param length the length in bits of the int to read. - * @param leastSignificantBitIndex the index of the least significant bit of the int to read. - * @return the int value read. - */ - public static int readBits(byte src, int length, int leastSignificantBitIndex) { - return (src >> leastSignificantBitIndex) & (255 >>> (8 - length)); - } - /** * Reads a vorbis identification header from {@code headerData}. * @@ -71,11 +58,11 @@ import java.util.Arrays; public static VorbisIdHeader readVorbisIdentificationHeader(ParsableByteArray headerData) throws ParserException { - captureVorbisHeader(0x01, headerData); + verifyVorbisHeaderCapturePattern(0x01, headerData, false); long version = headerData.readLittleEndianUnsignedInt(); int channels = headerData.readUnsignedByte(); - int sampleRate = (int) headerData.readLittleEndianUnsignedInt(); + long sampleRate = headerData.readLittleEndianUnsignedInt(); int bitrateMax = headerData.readLittleEndianInt(); int bitrateNominal = headerData.readLittleEndianInt(); int bitrateMin = headerData.readLittleEndianInt(); @@ -104,7 +91,8 @@ import java.util.Arrays; public static CommentHeader readVorbisCommentHeader(ParsableByteArray headerData) throws ParserException { - int length = captureVorbisHeader(0x03, headerData); + verifyVorbisHeaderCapturePattern(0x03, headerData, false); + int length = 7; int len = (int) headerData.readLittleEndianUnsignedInt(); length += 4; @@ -127,21 +115,40 @@ import java.util.Arrays; return new CommentHeader(vendor, comments, length); } - private static int captureVorbisHeader(int headerType, ParsableByteArray idHeader) + /** + * Verifies whether the next bytes in {@code header} are a vorbis header of the given + * {@code headerType}. + * + * @param headerType the type of the header expected. + * @param header the alleged header bytes. + * @param quite if {@code true} no exceptions are thrown. Instead {@code false} is returned. + * @return the number of bytes read. + * @throws ParserException thrown if header type or capture pattern is not as expected. + */ + public static boolean verifyVorbisHeaderCapturePattern(int headerType, ParsableByteArray header, + boolean quite) throws ParserException { - if (idHeader.readUnsignedByte() != headerType) { - throw new ParserException("expected header type " + Integer.toHexString(headerType)); + if (header.readUnsignedByte() != headerType) { + if (quite) { + return false; + } else { + throw new ParserException("expected header type " + Integer.toHexString(headerType)); + } } - if (!(idHeader.readUnsignedByte() == 'v' - && idHeader.readUnsignedByte() == 'o' - && idHeader.readUnsignedByte() == 'r' - && idHeader.readUnsignedByte() == 'b' - && idHeader.readUnsignedByte() == 'i' - && idHeader.readUnsignedByte() == 's')) { - throw new ParserException("expected characters 'vorbis'"); + if (!(header.readUnsignedByte() == 'v' + && header.readUnsignedByte() == 'o' + && header.readUnsignedByte() == 'r' + && header.readUnsignedByte() == 'b' + && header.readUnsignedByte() == 'i' + && header.readUnsignedByte() == 's')) { + if (quite) { + return false; + } else { + throw new ParserException("expected characters 'vorbis'"); + } } - return 7; // bytes read + return true; } /** @@ -159,7 +166,7 @@ import java.util.Arrays; public static Mode[] readVorbisModes(ParsableByteArray headerData, int channels) throws ParserException { - captureVorbisHeader(0x05, headerData); + verifyVorbisHeaderCapturePattern(0x05, headerData, false); int numberOfBooks = headerData.readUnsignedByte() + 1; @@ -433,7 +440,7 @@ import java.util.Arrays; public final long version; public final int channels; - public final int sampleRate; + public final long sampleRate; public final int bitrateMax; public final int bitrateNominal; public final int bitrateMin; @@ -442,7 +449,7 @@ import java.util.Arrays; public final boolean framingFlag; public final byte[] data; - public VorbisIdHeader(long version, int channels, int sampleRate, int bitrateMax, + public VorbisIdHeader(long version, int channels, long sampleRate, int bitrateMax, int bitrateNominal, int bitrateMin, int blockSize0, int blockSize1, boolean framingFlag, byte[] data) { this.version = version;